<?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/">
  <channel>
    <title>DEV Community: Ken W Alger</title>
    <description>The latest articles on DEV Community by Ken W Alger (@kenwalger).</description>
    <link>https://dev.to/kenwalger</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F15734%2F22d0195e-9fce-4d80-9ae2-3bb416bf8d6f.jpg</url>
      <title>DEV Community: Ken W Alger</title>
      <link>https://dev.to/kenwalger</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kenwalger"/>
    <language>en</language>
    <item>
      <title>Enterprise AI Governance in 2026</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Thu, 22 Oct 2026 15:58:00 +0000</pubDate>
      <link>https://dev.to/kenwalger/building-an-enterprise-ai-governance-stack-2985</link>
      <guid>https://dev.to/kenwalger/building-an-enterprise-ai-governance-stack-2985</guid>
      <description>&lt;p&gt;&lt;em&gt;An enterprise AI governance stack is not a gateway feature. It is a sequence of decisions that has to hold from organizational intent to runtime action.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Five posts into &lt;em&gt;The AI Control Plane&lt;/em&gt;, the individual controls are no longer the interesting part. Governance decisions, virtual keys, RBAC, access profiles, user lifecycle management, and MCP tool policy each solve a real problem. None of them is an enterprise AI governance stack by itself.&lt;/p&gt;

&lt;p&gt;The harder question is how they fit together, and in what order.&lt;/p&gt;

&lt;p&gt;Installing a gateway before deciding who may use which models gives you infrastructure without policy. Creating virtual keys before defining ownership gives you credentials without governance. Automating provisioning before cleaning up exceptions makes access sprawl faster. Giving agents tools before deciding which actions they may take converts model access into operational authority.&lt;/p&gt;

&lt;p&gt;It is tempting to deploy the gateway first, because infrastructure is concrete: point applications at a new base URL, centralize credentials, turn on observability, call phase one complete. Those are useful changes and they are not yet governance. Without an explicit decision layer, a gateway centralizes whatever assumptions were already scattered across applications, and centralized ambiguity is still ambiguity.&lt;/p&gt;

&lt;p&gt;A control plane does not create governance. It makes governance executable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Belongs in an Enterprise AI Governance Stack?
&lt;/h2&gt;

&lt;p&gt;A working stack answers a set of related questions in deliberate order. Governance decisions, virtual keys, RBAC, access profiles, user lifecycle management, MCP tool policy, and the evidence those controls leave behind each solve a real problem.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://dev.to/kenwalger/ai-governance-for-enterprise-ai-why-governance-comes-before-the-gateway-2hm1"&gt;Governance&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Who gets to decide?&lt;/td&gt;
&lt;td&gt;Organizational policy and ownership&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity&lt;/td&gt;
&lt;td&gt;Who is this?&lt;/td&gt;
&lt;td&gt;Enterprise identity provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://dev.to/kenwalger/rbac-for-ai-governing-the-ai-control-plane-e59"&gt;Authority&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;What may this administrator change?&lt;/td&gt;
&lt;td&gt;&lt;a href="https://docs.getbifrost.ai/enterprise/rbac" rel="noopener noreferrer"&gt;RBAC and scoped permissions&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://dev.to/kenwalger/ai-access-control-for-enterprise-ai-turning-policy-into-runtime-enforcement-5bkk"&gt;Runtime policy&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;What may this workload use?&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://docs.getbifrost.ai/enterprise/access-profiles" rel="noopener noreferrer"&gt;Access profiles&lt;/a&gt; and &lt;a href="https://docs.getbifrost.ai/features/governance/virtual-keys" rel="noopener noreferrer"&gt;virtual keys&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://dev.to/kenwalger/why-user-provisioning-matters-for-enterprise-ai-534m"&gt;Lifecycle&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;How does authority change over time?&lt;/td&gt;
&lt;td&gt;&lt;a href="https://docs.getbifrost.ai/enterprise/user-provisioning" rel="noopener noreferrer"&gt;Provisioning and reconciliation&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://dev.to/kenwalger/enterprise-mcp-gateway-to-govern-mcp-tool-access-mam"&gt;Capability&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;What may this workload actually do?&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://docs.getbifrost.ai/features/governance/mcp-tools" rel="noopener noreferrer"&gt;MCP tool filtering&lt;/a&gt; and execution policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evidence&lt;/td&gt;
&lt;td&gt;What happened under that authority?&lt;/td&gt;
&lt;td&gt;&lt;a href="https://docs.getbifrost.ai/enterprise/audit-logs" rel="noopener noreferrer"&gt;Logs, audit records, and retained state&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The important part is not the names in the right-hand column. Implementations change. The questions do not.&lt;/p&gt;

&lt;p&gt;This is also why starting from a product checklist produces weak governance. "Do we have RBAC?" is less useful than "who is allowed to change a production model allow list?" "Do we support MCP?" matters less than "which workloads may invoke a destructive tool with no human present?" Design the stack around decisions, then map those decisions to mechanisms capable of enforcing them. The &lt;a href="https://www.nist.gov/itl/ai-risk-management-framework" rel="noopener noreferrer"&gt;NIST AI Risk Management Framework&lt;/a&gt; is organized the same way, around functions like govern, map, measure, and manage rather than around any particular control implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Is Role-Based Access Control Different from Identity in AI Governance?
&lt;/h2&gt;

&lt;p&gt;Once policy exists, the next temptation is to treat authentication as authorization.&lt;/p&gt;

&lt;p&gt;An identity provider can establish that Alice works in Engineering and belongs to the Platform group. It does not know whether Alice should be able to change provider credentials, reveal redacted logs, edit access profiles, or grant an agent permission to invoke a tool. The administrative surface needs its own authorization model, with scope determining not just what an administrator may change but where.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Identity answers who. RBAC answers who may decide. Runtime policy answers what the workload may do.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Collapsing those layers creates shortcuts that look convenient during rollout and are painful to unwind. A broad administrator role becomes the place every exception lands, direct assignments accumulate because they solve today's problem faster than changing a role does, and eventually nobody can explain whether access exists because of policy or because someone needed it during an incident eighteen months ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Should an AI Gateway Enforce Governance Policy?
&lt;/h2&gt;

&lt;p&gt;A control plane can be introduced gradually. Governance cannot stay optional forever.&lt;/p&gt;

&lt;p&gt;Earlier in this series there was an uncomfortable default worth returning to: allowing requests without governed credentials makes migration easier and also creates a bypass. That is reasonable while applications are moving behind the gateway. It is dangerous once leadership believes the migration is finished.&lt;/p&gt;

&lt;p&gt;So the transition needs an explicit enforcement point, and the sequence into it is the part teams get wrong.&lt;/p&gt;

&lt;p&gt;Inventory the workloads. Give them owners. Attach budgets, provider and model policy, expiry where appropriate, and tool permissions. Move traffic onto governed credentials. Find the systems that break. Fix those exceptions rather than exempting them permanently. Then require governed identity at inference.&lt;/p&gt;

&lt;p&gt;Turning mandatory enforcement on before inventory and ownership creates an outage. Leaving it optional after migration leaves a control that exists on paper and constrains nothing. The control is present in both cases; only one sequence produces governance.&lt;/p&gt;

&lt;p&gt;The same principle applies to budgets and tool access. Start with observation where you must, but decide in advance when observation becomes enforcement. A policy that stays permanently advisory is telemetry, not control.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Does Identity Lifecycle Management Apply to AI Access?
&lt;/h2&gt;

&lt;p&gt;Governance is never finished, because organizations do not stay configured. People join, teams reorganize, acquisitions create new reporting lines, projects end, employees leave, and service accounts outlive the people who created them.&lt;/p&gt;

&lt;p&gt;The lifecycle has to reach runtime objects, not just identities. Deactivating an SSO account accomplishes little if runtime credentials or upstream provider tokens remain independently usable. Deleting every object belonging to a leaver accomplishes something worse, because it destroys the record needed six months later to explain what that person could do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authority has to expire without history disappearing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And some actors never enter this lifecycle at all. Pipelines, shared service accounts, and manually issued credentials need explicit owners and review periods, because no HR event will ever deprovision them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Govern MCP Tools and AI Agents?
&lt;/h2&gt;

&lt;p&gt;Tool governance belongs late in the sequence, not because it matters least but because it depends on nearly everything above it. Before granting a workload access to a tool, the organization should already know who owns that workload, which policy applies, who may change that policy, how the credential is governed, and what happens when ownership changes.&lt;/p&gt;

&lt;p&gt;Only then is "may this agent invoke &lt;code&gt;issue_refund&lt;/code&gt;?" a meaningful question.&lt;/p&gt;

&lt;p&gt;Grant tool policy by consequence rather than convenience. Reading documentation, modifying a customer record, moving money, and deleting an account are not equivalent just because they arrive through the same protocol. And permissions compose: a database reader and an email sender may each be acceptable alone while together enabling a workflow nobody approved. Per-tool controls do not solve that. Governance has to review effective capability, not individual grants.&lt;/p&gt;

&lt;h2&gt;
  
  
  In What Order Should You Build an Enterprise AI Governance Stack?
&lt;/h2&gt;

&lt;p&gt;Put together, the sequence looks less like a product rollout and more like dependency management.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Define governance.&lt;/strong&gt; Ownership, approved providers and models, budgets, exception authority, escalation paths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connect identity.&lt;/strong&gt; Use the existing enterprise identity system rather than building a second silo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Constrain administrators.&lt;/strong&gt; RBAC and resource scope on the people who can change AI policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encode runtime policy.&lt;/strong&gt; Model access, budgets, rate limits, and expiry represented in governed credentials and profiles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inventory and migrate workloads.&lt;/strong&gt; Every credential gets an owner; inference moves behind the control plane.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make enforcement mandatory.&lt;/strong&gt; Close the ungoverned path once migration is done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automate lifecycle.&lt;/strong&gt; Reconcile joiners, movers, leavers, direct exceptions, and non-human identities.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Govern capabilities.&lt;/strong&gt; Add tools by consequence, and review what combinations of grants make possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preserve evidence.&lt;/strong&gt; Retain enough state to explain who held authority, what policy applied, and what happened under it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rollouts vary. A company already deep into agentic systems may need to inventory tool access before it can cleanly redesign identity. Another may have mature IAM and no centralized AI policy at all. This is not a migration script.&lt;/p&gt;

&lt;p&gt;It is a dependency graph. Skipping ahead will not necessarily make a later control fail technically. It makes the guarantee underneath it weaker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Access Control Isn't Enough: AI Guardrails and the OWASP LLM Top 10
&lt;/h2&gt;

&lt;p&gt;One honest limit, and it is a large one.&lt;/p&gt;

&lt;p&gt;Everything in these six posts governs access and authority. Who decided, who may change the decision, what the workload may reach, what it may do, and what record survives. None of it governs whether the output is any good.&lt;/p&gt;

&lt;p&gt;A perfectly governed request, from an approved workload, to an approved model, inside budget, invoking a permitted tool, can still return something wrong, biased, leaky, or harmful. No amount of RBAC catches that. Access control asks whether the request was allowed. It never asks whether the answer was correct.&lt;/p&gt;

&lt;p&gt;That is a different discipline with different mechanisms: content guardrails, evaluations, red-teaming, output monitoring, and human review on consequential paths. Some of them can run in the same place. Bifrost applies &lt;a href="https://docs.getbifrost.ai/enterprise/guardrails" rel="noopener noreferrer"&gt;guardrails&lt;/a&gt; to inference inputs and outputs and to MCP tool calls, so the enforcement point can be shared even where the disciplines are not. The OWASP &lt;a href="https://genai.owasp.org/llm-top-10/" rel="noopener noreferrer"&gt;GenAI LLM Top 10&lt;/a&gt; is a reasonable map of what that second discipline has to cover, and very little of it is an access control problem. An organization that builds one while ignoring the other has solved one class of problem and left another largely untouched.&lt;/p&gt;

&lt;p&gt;Which raises the question this series has circled without answering. The ninth step says preserve evidence. But six months after an incident, what does that evidence actually prove, who is allowed to read it, and would it survive someone with an interest in its being different?&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI Control Plane Is Where Decisions Become Behavior
&lt;/h2&gt;

&lt;p&gt;Across this series, &lt;a href="https://getmax.im/githubdevto" rel="noopener noreferrer"&gt;Bifrost&lt;/a&gt; has been the concrete implementation of an enterprise AI governance stack because it puts many of these controls in one place: routing, virtual keys, budgets and limits, RBAC, access profiles, user provisioning, guardrails, and MCP governance. It is open source, while some of the enterprise identity and administrative capabilities discussed here sit in its Enterprise offering, and the &lt;a href="https://docs.getbifrost.ai/overview" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; is the right place to verify that boundary.&lt;/p&gt;

&lt;p&gt;The architectural argument is larger than any one implementation. Enterprise AI governance is not a collection of toggles bolted around a model API. It is a chain of authority running from an organizational decision to a runtime action, where every link answers a different question and every downstream guarantee assumes the upstream answers were legitimate.&lt;/p&gt;

&lt;p&gt;A budget limit means little if anyone can raise it. A virtual key means little if ungoverned traffic routes around it. RBAC means little if direct exceptions never expire. Offboarding means little if autonomous credentials outlive the person who owned them. Tool filtering means little if nobody asks what the permitted tools can accomplish together.&lt;/p&gt;

&lt;p&gt;The point of a control plane is not to make those problems disappear. It is to give an organization one place where its decisions become observable, enforceable behavior.&lt;/p&gt;

&lt;p&gt;That is the enterprise AI governance stack.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Teams that want to see these controls running end to end can &lt;a href="https://getmaxim.ai/bifrost/book-a-demo" rel="noopener noreferrer"&gt;book a Bifrost demo&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;This article was commissioned by the &lt;a href="https://www.getmaxim.ai/bifrost" rel="sponsored nofollow noopener noreferrer"&gt;Bifrost team&lt;/a&gt;. The link to the &lt;a href="https://sovereignplatform.dev?utm_source=devto&amp;amp;utm_medium=footer&amp;amp;utm_campaign=ai-control-plane" rel="noopener noreferrer"&gt;Sovereign Systems Specification&lt;/a&gt; points to my own work. The architectural perspective and conclusions expressed here are my own.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Nothing Reported It</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Thu, 22 Oct 2026 13:34:00 +0000</pubDate>
      <link>https://dev.to/kenwalger/nothing-reported-it-9ke</link>
      <guid>https://dev.to/kenwalger/nothing-reported-it-9ke</guid>
      <description>&lt;h3&gt;
  
  
  Five ways my own test harness lied to me, and the one that nearly got published
&lt;/h3&gt;

&lt;p&gt;The number that gave it away was sitting in a table I had already read three times.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prompt size    97 tokens median, 112 max
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the grounded question-answering workflow. It sends four reference documents with every question. The documents are 447 words.&lt;/p&gt;

&lt;p&gt;Four hundred and forty-seven words cannot be ninety-seven tokens.&lt;/p&gt;

&lt;p&gt;I had written that number into a results document as good news about context headroom. "Prompt sizes never approached the 4096-token context window." It was not good news about headroom. It was the harness telling me, in the only field capable of telling me, that the reference material was not in the prompt. The directory holding it had never been committed to the repository. Every one of those fifteen questions had been asked against an empty reference block.&lt;/p&gt;

&lt;p&gt;Both models had behaved impeccably. One had been instructed to answer &lt;code&gt;NOT IN THE MATERIAL&lt;/code&gt; when the material did not contain the answer, and the material contained nothing, so it said &lt;code&gt;NOT IN THE MATERIAL&lt;/code&gt; thirteen times out of fifteen. The other had no such instruction, so it answered from whatever it had memorized during training and invented a wine.&lt;/p&gt;

&lt;p&gt;Every call returned 200. The scorer scored it. The scores were internally consistent, had plausible and &lt;em&gt;different&lt;/em&gt; failure modes on each side, and I wrote two thousand seven hundred words about what they revealed regarding the capabilities of small language models.&lt;/p&gt;

&lt;p&gt;They revealed nothing about small language models. I had measured what happens when you ask a question with no reference material, which is a question I was not asking and which has an uninteresting answer.&lt;/p&gt;

&lt;p&gt;This is the post about that, and about the four other things I found in the same weekend, because the missing directory was not the first one and it was not even the most instructive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The experiment, briefly
&lt;/h2&gt;

&lt;p&gt;A Raspberry Pi 4, two small models through Ollama, and three workflows: pull an identifier out of a message, route a message to one of six queues, answer a question from four documents. Ninety-five test cases. A deterministic baseline for the first workflow. Predictions committed before anything was installed.&lt;/p&gt;

&lt;p&gt;The findings are a separate post. This one is about the code I wrote to produce them, which turned out to be where the actual bugs were.&lt;/p&gt;

&lt;p&gt;Five flaws. Every single one was found by reading output, not by reading code. I never once found one of these by looking at the scorer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kind one: a metric that rewards the safe failure
&lt;/h2&gt;

&lt;p&gt;This one I hit twice, in two different workflows, and did not recognize the second instance as the same bug until I had already fixed the first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The classification scorer.&lt;/strong&gt; Six labels, one of which is &lt;code&gt;escalate&lt;/code&gt;. The instruction said to escalate when a message needs a person, including when you cannot tell what it is about. So I built a metric called &lt;em&gt;confident wrong&lt;/em&gt;: wrong, and not an escalation. The reasoning is sound. A model that escalates when unsure is usable. A model that routes a trademark notice to billing is not.&lt;/p&gt;

&lt;p&gt;Then the 360M model answered &lt;code&gt;escalate&lt;/code&gt; to 43 of 50 messages.&lt;/p&gt;

&lt;p&gt;It scored &lt;strong&gt;3&lt;/strong&gt; on confident wrong. The 1.5B model, which was genuinely attempting the task and getting 46% of it right, scored &lt;strong&gt;26&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;My metric had just certified the useless model as roughly six times safer than the mediocre one. Because escalations are exempt, a model that escalates &lt;em&gt;always&lt;/em&gt; cannot register a confident error. The exemption I added to reward caution had instead rewarded paralysis, and it did so in the direction that looks best.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The grounded QA scorer.&lt;/strong&gt; Four of the fifteen questions have no answer in the corpus. Correctly saying so is the behavior that matters most, because a system that invents a plausible answer with no evidence is worse than one that fails loudly: it is indistinguishable from one that worked. So the headline number was correct abstentions out of four.&lt;/p&gt;

&lt;p&gt;Then the 1.5B model said &lt;code&gt;NOT IN THE MATERIAL&lt;/code&gt; to 13 of 15 questions.&lt;/p&gt;

&lt;p&gt;It scored &lt;strong&gt;4 out of 4&lt;/strong&gt;. A perfect score, on the metric I cared most about, for a model that had answered almost nothing.&lt;/p&gt;

&lt;p&gt;Same bug. Different workflow. Two hours apart. I fixed the classification one, wrote a comment in the code explaining the principle, and then failed to apply that principle to the file I opened next.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The shape, stated once:&lt;/strong&gt; a metric that rewards the safe behavior will certify any model that only ever does the safe thing. If you exempt a behavior because it is cautious, you have to pair that exemption with evidence that the model is making distinctions at all. Otherwise, the exemption &lt;em&gt;is&lt;/em&gt; the score.&lt;/p&gt;

&lt;p&gt;Both fixes are the same three lines: count how concentrated the answers are, and when one answer dominates, print a warning that the headline number is meaningless and say why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kind two: a metric that measures nothing at all
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The vacuous fixture.&lt;/strong&gt; I caught this one before the run, which is the only reason it is not a bigger part of this post. A test case that passed regardless of what the model did, because the fixture was inert either way. It contributed a tick to a column and no information.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The keyword check&lt;/strong&gt;, which is worse, because it survived longer and looked more useful.&lt;/p&gt;

&lt;p&gt;The eleven answerable QA questions each carry a list of words the correct answer should contain. The scorer checks whether any of them appear anywhere in the reply. I labeled it "indicative only" and moved on.&lt;/p&gt;

&lt;p&gt;On the invalid run it scored 1 of 11 for both models. The single hit was the word "reservation", appearing inside this answer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You owe a tasting fee of $100 for the weekend. You also need to book a reservation for the weekend.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The correct fee is forty dollars. The hundred is invented. That one hit was the entire positive signal in my automated QA scoring and it was noise.&lt;/p&gt;

&lt;p&gt;Here is the part that makes it a better story. After I fixed the corpus and reran, the keyword check scored &lt;strong&gt;11 of 11&lt;/strong&gt; for the 1.5B model. Vindicated, apparently. I read all eleven answers by hand anyway, and nine of them are right.&lt;/p&gt;

&lt;p&gt;So the check was wrong both times. It was wrong in the pessimistic direction when the answers were bad and wrong in the optimistic direction when they were good, and on the rerun one of its eleven hits is an answer that gets the arithmetic wrong and hits on the word "reservation" anyway.&lt;/p&gt;

&lt;p&gt;A check that agrees with the truth because the truth happened to be agreeable has not been validated. It has been lucky.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kind three: a detector with a blind spot in the obvious direction
&lt;/h2&gt;

&lt;p&gt;I wrote a decay check because of a thermal problem. A Pi 4 with heat sinks and no fan hit its soft temperature limit during a thirty-call run and started capping the clock, and nothing anywhere said so. So the scorer now compares generation speed in the first third of a run to the last third and flags a slowdown.&lt;/p&gt;

&lt;p&gt;It then reported this, as &lt;em&gt;steady&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;across the run 4.3 -&amp;gt; 7.6 tok/s (176% of starting speed, steady)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hardware got seventy-six percent faster partway through a run. My detector, which was looking for numbers below a threshold, saw a number above it and reported no problem.&lt;/p&gt;

&lt;p&gt;Hardware does not speed up. That reading is either contention at the start of the run or, as it turned out, generations too short to time. Either way it is a signal that the measurement is unreliable, and I had built a detector that would only ever notice half of the ways that can happen.&lt;/p&gt;

&lt;p&gt;Anything you threshold in one direction, threshold in both. An implausibly good number is as informative as an implausibly bad one and nobody ever writes the check for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kind four: errors that get dropped instead of counted
&lt;/h2&gt;

&lt;p&gt;Partway through the first extraction run, Ollama returned a 500 and the runner died at case seven of thirty. The traceback was useless because &lt;code&gt;urllib&lt;/code&gt; raises &lt;code&gt;HTTPError&lt;/code&gt; and discards the response body, which is where Ollama puts its reason. Reading &lt;code&gt;e.read()&lt;/code&gt; gave it up immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"prediction aborted, token repeat limit reached"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 360M model, asked to find an order number in "Thanks for a lovely afternoon yesterday," where the correct answer is one word, had fallen into a repetition loop and been killed after twenty-one seconds. Reproducible at temperature zero. The input causes it.&lt;/p&gt;

&lt;p&gt;My first fix was to record failures and keep going, which was right. My second was to exclude all failures from the scores as infrastructure noise, which was badly wrong.&lt;/p&gt;

&lt;p&gt;That call was not infrastructure noise. The model ran. It received the prompt, generated tokens, and produced nothing usable. That is a result, and excluding it would have dropped from the denominator the single case this model demonstrably could not handle, scoring it only on the cases it survived.&lt;/p&gt;

&lt;p&gt;So the runner now tags every failure as one of two kinds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model failure.&lt;/strong&gt; The call reached a working model and the model failed. Stays in the denominator. Counts against the model. Reported on its own line as "no usable answer."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transport failure.&lt;/strong&gt; The call never reached a working model. Ollama was down, the tag was wrong, the request timed out. Nothing was measured, so it is excluded and the run is reported as incomplete.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The distinction is a five-line change and it is the difference between measuring a model and measuring a model on the subset of inputs it happens to tolerate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kind five: inputs that go missing without raising
&lt;/h2&gt;

&lt;p&gt;This is the missing corpus from the top of the post, and it is a different and worse category than everything above it.&lt;/p&gt;

&lt;p&gt;The function was four lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;corpus_text&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;FIX&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;corpus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A glob that matches nothing yields nothing. An empty list joins to the empty string. No exception, no warning, no exit code. It returns a perfectly valid value that happens to be the absence of the thing it was asked for.&lt;/p&gt;

&lt;p&gt;And on the same evening, a sibling. The offline test's entire product is a text transcript. I copied it into the run directory, committed, pushed, and it was not there. The standard Python &lt;code&gt;.gitignore&lt;/code&gt; template, which I had pulled in wholesale, carries &lt;code&gt;*.log&lt;/code&gt; on line 165, and a pattern without a leading slash matches at any depth.&lt;/p&gt;

&lt;p&gt;The copy succeeded. &lt;code&gt;git status&lt;/code&gt; came back clean. The push reported success. The one artifact the test exists to produce was gone, and everything in the chain said fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Absence does not raise.&lt;/strong&gt; A missing directory, a missing file and an inert fixture all present as an ordinary successful run. That is narrower and more useful than "test your tests," and it generalizes: every input a harness depends on needs a loader that refuses to proceed without it, and every artifact it produces needs to be verified as present rather than inferred from an exit status.&lt;/p&gt;

&lt;p&gt;The loader now looks like this, and the comment is longer than the code because the comment is the part I will need next year:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;paths&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;corpus_dir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SystemExit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No corpus documents found in &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;corpus_dir&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Grounded QA cannot run without reference material, and running&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;it against an empty reference block produces numbers that look&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;like a result.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also refuses a corpus implausibly smaller than the real one, the run prints the document count and token estimate before its first call, and the scorer independently flags any QA run whose median prompt is under 400 tokens. Three checks for one bug, in the input, in the run, and in the output, because this is the bug that cost a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The detector that fired, and was right, and told me the wrong thing
&lt;/h2&gt;

&lt;p&gt;Here is the part I find hardest to write and most worth keeping.&lt;/p&gt;

&lt;p&gt;The abstention-collapse check from kind one &lt;em&gt;did&lt;/em&gt; fire on the invalid run. It printed exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;!! ABSTAINED on 13/15 questions overall. It is refusing, not judging.
   The abstention score below is not evidence of discrimination:
   a model that never answers gets it right for free.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It was correct. Something was badly wrong with that run.&lt;/p&gt;

&lt;p&gt;Its diagnosis was completely false. The model was not refusing. It was obeying, precisely, an instruction to report when the material lacks the answer, in a situation where the material lacked everything. The fault was not in the model's judgment, and it was not in the metric. It was in a directory that did not exist, two layers upstream.&lt;/p&gt;

&lt;p&gt;And I acted on the diagnosis. I wrote it up: the model has a stuck needle, its perfect abstention score is an artifact of refusal, here are the implications for anyone building on a 1.5B model. Confident, specific, sourced to real output, and wrong in every particular.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A flag that tells you something is off is not a flag that tells you what.&lt;/strong&gt; The check could only see the output. The fault was in the input. It fired for the right reason and pointed in the wrong direction, and because it fired I felt &lt;em&gt;more&lt;/em&gt; confident in a conclusion that was false, not less.&lt;/p&gt;

&lt;p&gt;That is the failure mode I had no defense against and still only partly do. A monitoring system that is right about the existence of a problem and wrong about its cause is more dangerous than no monitoring, because it terminates the search.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually changed
&lt;/h2&gt;

&lt;p&gt;For anyone who wants the diffs rather than the moral:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Collapse checks on both classification and QA: when one answer exceeds half the set, print the concentration and declare the headline metric meaningless for that model.&lt;/li&gt;
&lt;li&gt;The decay check flags implausible speedups as well as slowdowns, and notes when generations are too short to time.&lt;/li&gt;
&lt;li&gt;Failures split into model and transport, with model failures staying in the denominator.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;corpus_text()&lt;/code&gt; raises on an empty or implausibly small corpus.&lt;/li&gt;
&lt;li&gt;The QA run prints its corpus size before the first call.&lt;/li&gt;
&lt;li&gt;The scorer flags a QA run whose prompts are too small to contain the corpus.&lt;/li&gt;
&lt;li&gt;The keyword check is relabelled as a weak lower bound, in the output, in the README, and in the results.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.gitignore&lt;/code&gt; no longer lets &lt;code&gt;*.log&lt;/code&gt; swallow run transcripts.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vcgencmd get_throttled&lt;/code&gt; before and after every run, recorded in the run document rather than checked and forgotten. The reading reports bits set since boot, so it is perishable: power-cycle the board and the evidence is gone.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The uncomfortable part
&lt;/h2&gt;

&lt;p&gt;Every one of these was found by looking at data. Not one was found by reading code, reviewing the scorer, or writing a unit test for the harness.&lt;/p&gt;

&lt;p&gt;The vacuous fixture turned up while hand-checking a passing case. The confident-wrong flaw turned up because a score of 3 against 26 looked backwards. The abstention flaw turned up because 4 out of 4 looked too good. The keyword flaw turned up because 1 of 11 looked too bad, and I read the one. The missing corpus turned up because I was double-checking a latency figure and noticed a prompt that was too small to be possible.&lt;/p&gt;

&lt;p&gt;Which means the only reason I found them is that I kept pulling threads after I had numbers that looked publishable. The version of this project that shipped on schedule shipped on Tuesday, with a clean verdict table, a confident conclusion about small models and grounded question answering, and a repository anyone could clone to reproduce the exact same wrong result.&lt;/p&gt;

&lt;p&gt;Every number in it would have been reproducible. That is the thing I keep turning over. Reproducibility is table stakes and it would not have saved me, because the harness reproduced its own bug faithfully every single time.&lt;/p&gt;

&lt;p&gt;I do not have a process that catches this reliably. What I have is a weaker and more honest claim: the checks I added are each a direct response to one specific thing that actually happened, which is the only kind of check I trust, and the number of them that I thought up in advance is zero.&lt;/p&gt;

&lt;p&gt;So the two rows I am adding to my prediction template, to be filled in before anything runs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;What would have to be true for these numbers to be meaningless?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would anything in the harness tell me if it were?&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Twenty-two predictions about what the models would do. Not one about what the measuring code would do. Four of the five flaws were in the measuring code.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The harness, the fixtures, the committed predictions and the full run record, including the invalid run and what it produced, are at&lt;br&gt;
&lt;a href="https://github.com/kenwalger/right-size-the-model" rel="noopener noreferrer"&gt;github.com/kenwalger/right-size-the-model&lt;/a&gt;.&lt;br&gt;
The findings the harness eventually produced are in &lt;a href="[https://www.kenwalger.com/blog/ai/do-you-actually-need-an-llm/](https://www.kenwalger.com/blog/ai/do-you-actually-need-an-llm/?utm_source=kenwalger&amp;amp;utm_medium=blog&amp;amp;utm_campaign=right_size_the_model)"&gt;the companion post&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>programming</category>
      <category>devops</category>
    </item>
    <item>
      <title>Five World Series Pitching Predictions, Locked Before Game 1</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Tue, 20 Oct 2026 18:20:49 +0000</pubDate>
      <link>https://dev.to/kenwalger/five-world-series-pitching-predictions-locked-before-game-1-ad7</link>
      <guid>https://dev.to/kenwalger/five-world-series-pitching-predictions-locked-before-game-1-ad7</guid>
      <description>&lt;p&gt;&lt;em&gt;Written down now, graded later, no edits allowed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The Brewers and the Rays open the World Series on 23 October 2026. Before the first pitch, I'm putting five predictions about the Series' starting pitching on the record.&lt;/p&gt;

&lt;p&gt;Most predictions get forgotten the moment they turn out wrong. These won't. After the final out, I'll run every starter's pitches through a little Statcast app I built and grade each one in public, misses included.&lt;/p&gt;

&lt;p&gt;The main stat is &lt;strong&gt;CSW%&lt;/strong&gt;: the share of a pitcher's pitches that end in a called strike or a swing and miss. A pitch the umpire calls a strike, or one the hitter whiffs on, is a pitch the pitcher won. To be ranked, a starter needs at least 75 pitches across his Series starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The predictions
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The CSW leader will clear 32%.&lt;/strong&gt; The Series' most effective qualifying starter, using the 75-pitch minimum, will finish with a CSW% of at least 32%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At least one starter won't qualify.&lt;/strong&gt; At least one pitcher who starts a World Series game will finish the Series with fewer than 75 pitches thrown as a starter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A rematch will produce a different arsenal.&lt;/strong&gt; At least one starter making two Series starts will change the usage rate of one pitch type by 15 percentage points or more between appearances.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Misiorowski will reach 105.&lt;/strong&gt; Jacob Misiorowski will throw at least one pitch of 105 mph or faster in the Series, per Statcast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freddy Peralta won't lead the CSW rankings.&lt;/strong&gt; Despite his ALCS MVP performance, Peralta will not finish as the World Series' highest-CSW qualifying starter.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two of these I'd bet on, two are coin flips, and one is me hoping for something fun.&lt;/p&gt;

&lt;h2&gt;
  
  
  Play along
&lt;/h2&gt;

&lt;p&gt;The app is &lt;a href="https://pitching-stats.onrender.com/" rel="noopener noreferrer"&gt;live&lt;/a&gt;, and an entire World Series fits inside its 14-day limit, so once the Series is over you can grade these yourself before I do. If you think I'm wrong about any of them, or you have a better prediction, leave it in the comments. Those get graded too.&lt;/p&gt;

&lt;p&gt;The full write-up, with the grades, lands after the final out.&lt;/p&gt;

</description>
      <category>sports</category>
      <category>discuss</category>
      <category>python</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Tuesday Isn't a Valid State Transition</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Tue, 20 Oct 2026 13:46:00 +0000</pubDate>
      <link>https://dev.to/kenwalger/tuesday-isnt-a-valid-state-transition-16ln</link>
      <guid>https://dev.to/kenwalger/tuesday-isnt-a-valid-state-transition-16ln</guid>
      <description>&lt;p&gt;&lt;em&gt;Calendars, evidence, and the difference between done and stuck&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I expected my pear wine to spend two to three weeks in primary fermentation.&lt;/p&gt;

&lt;p&gt;It spent nineteen days. The yeast went in on September 23, and on October 12 I siphoned the wine off its sediment and into a glass carboy.&lt;/p&gt;

&lt;p&gt;So the calendar was right. That still isn't why I moved it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the calendar is for
&lt;/h2&gt;

&lt;p&gt;Every fermentation recipe comes with time estimates. Primary takes one to three weeks. Secondary takes a few more. Bulk age for a few months. Those numbers aren't wrong. They're averages across a lot of batches, and they're useful for exactly one thing: planning. They tell you when to have a clean carboy ready, when to clear some space, when to stop making weekend plans.&lt;/p&gt;

&lt;p&gt;What they can't do is tell you that &lt;em&gt;this&lt;/em&gt; batch is ready. Yeast doesn't read recipes. It works at a speed set by temperature, sugar, nutrients, acidity, the particular strain, and the particular fruit, none of which the recipe author has ever met.&lt;/p&gt;

&lt;p&gt;So the recipe gives you a forecast, and the fermentation gives you evidence. Only one of those gets to move the wine. If they happen to agree, as they did this time, that's a pleasant coincidence, not an authorization.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule winemakers actually use
&lt;/h2&gt;

&lt;p&gt;The traditional test for "primary is done, time to rack" is simple: take readings over several days, and when you get the same reading three times running, fermentation has finished.&lt;/p&gt;

&lt;p&gt;What I like about that rule is what it &lt;em&gt;isn't&lt;/em&gt;. It isn't a date. It also isn't a single reading, because one measurement tells you where the fermentation is, not whether it's still moving. It's evidence sustained over a window. Time is part of the test, but only as the space in which the evidence has to hold still.&lt;/p&gt;

&lt;p&gt;Here's what the pear's refractometer readings actually looked like:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Raw Brix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sep 23&lt;/td&gt;
&lt;td&gt;12.9 (before the yeast)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 25&lt;/td&gt;
&lt;td&gt;6.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 30&lt;/td&gt;
&lt;td&gt;6.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 2&lt;/td&gt;
&lt;td&gt;5.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 4&lt;/td&gt;
&lt;td&gt;5.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 7&lt;/td&gt;
&lt;td&gt;5.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 10&lt;/td&gt;
&lt;td&gt;5.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 11&lt;/td&gt;
&lt;td&gt;5.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 12&lt;/td&gt;
&lt;td&gt;5.6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things in that table are worth a closer look.&lt;/p&gt;

&lt;p&gt;First, the early plateau. Most of the sugar was gone within two days, and then the reading sat at exactly 6.0 for five days. If I'd been looking for stillness and nothing else, that looked a lot like the end. It wasn't. The fermentation was slowing down, not stopping, and over the next week it crept down further before it finally held. Two matching readings were a pause, not a verdict, which is precisely why the rule asks for three.&lt;/p&gt;

&lt;p&gt;Second, I didn't stop at three. By October 10 I had three consecutive readings at 5.6, which satisfies the rule. I kept reading anyway, and waited until five readings across eight days agreed.&lt;/p&gt;

&lt;p&gt;Then the racking itself handed me one more piece of evidence. There was more wine in the primary fermenter than the glass carboy could hold. Rather than pour the extra down the drain, I filled a hydrometer jar with it: a completely different instrument, measuring a completely different physical property. The refractometer, once corrected for alcohol, put the wine at about 1.004. The hydrometer said 1.002. Two independent instruments, close enough to agree, and the second one confirmed that the correction formula I'd been trusting for three weeks was doing its job.&lt;/p&gt;

&lt;p&gt;I like that this happened by accident, because it isn't really an accident. A hydrometer needs a large sample, which in a one-gallon batch is too expensive to take routinely. At the moment of a transition, when wine is being moved anyway, that same sample costs nothing. The transition is when the more trustworthy evidence becomes cheap.&lt;/p&gt;

&lt;p&gt;None of that is the calendar talking. That's a guard, and it was satisfied before the wine moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Status is a column anyone can write
&lt;/h2&gt;

&lt;p&gt;In a lot of systems, state is a string in a database, and a transition is an &lt;code&gt;UPDATE&lt;/code&gt; statement.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'shipped'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4417&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in that statement requires that anything was shipped. It's a label, and anyone with write access can apply it for any reason: a cron job that marks orders shipped two days after payment because that's usually true, a release train that promotes whatever's on the branch because it's Tuesday, a support tool that flips a flag to make a ticket go away.&lt;/p&gt;

&lt;p&gt;Each of these is the software version of racking the wine because the recipe said two to three weeks. Most of the time it'll even be right, just as my calendar was. The transition happens on schedule, the dashboard updates, and the system now asserts a state that nobody verified.&lt;/p&gt;

&lt;p&gt;The fix isn't elaborate. A transition should have a guard, a condition that has to be true, based on evidence, before the state can change. "Shipped" should require a carrier scan. "Released" should require the checks that define releasable. "Primary complete" should require stable readings. The state machine's job is to refuse the transition when the evidence isn't there, however much the calendar insists, and to keep refusing even on the days the calendar happens to be right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same evidence, two states
&lt;/h2&gt;

&lt;p&gt;Here's where it gets interesting. "Three consistent readings" is the signal for &lt;em&gt;done&lt;/em&gt;. But a fermentation that has stalled also produces three consistent readings.&lt;/p&gt;

&lt;p&gt;Mead is notorious for this. Honey has very little natural buffering, so as fermentation proceeds the pH drifts down, and past a certain point the yeast slows or quits entirely, often well short of dry. From the refractometer's point of view, a stuck mead and a finished mead look exactly the same: a number that has stopped moving.&lt;/p&gt;

&lt;p&gt;My fireweed mead is a few days from that question right now. Its pH started at 3.5 and dropped to 3.1 within the first four days, where it has mostly stayed. Here are its last few readings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Raw Brix&lt;/th&gt;
&lt;th&gt;Corrected Brix&lt;/th&gt;
&lt;th&gt;Estimated ABV&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Oct 7&lt;/td&gt;
&lt;td&gt;11.8&lt;/td&gt;
&lt;td&gt;4.3&lt;/td&gt;
&lt;td&gt;10.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 10&lt;/td&gt;
&lt;td&gt;9.0&lt;/td&gt;
&lt;td&gt;-0.3&lt;/td&gt;
&lt;td&gt;12.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 11&lt;/td&gt;
&lt;td&gt;8.6&lt;/td&gt;
&lt;td&gt;-1.0&lt;/td&gt;
&lt;td&gt;13.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oct 12&lt;/td&gt;
&lt;td&gt;8.4&lt;/td&gt;
&lt;td&gt;-1.3&lt;/td&gt;
&lt;td&gt;13.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at the corrected column. The calculator now claims the mead contains less than no sugar, which is impossible. That's a model being pushed past the range it was built for, and it's a nice reminder that a derived number can confidently assert something no physical system can be. If my guard were "corrected Brix at or below zero," I'd have racked the mead two days ago.&lt;/p&gt;

&lt;p&gt;The raw readings tell a different story: they're still falling, slowly. The mead isn't still yet, so the guard isn't satisfied, no matter what the corrected number says. And when it does go still, the question becomes &lt;em&gt;where&lt;/em&gt;. The yeast I used, 71B, is generally rated to tolerate around 14% alcohol, and the estimate is already over 13%. A mead that stops near its yeast's limit with the sugar it was expected to consume gone is probably done. One that stops early, with sugar left over and the pH low, is probably stuck.&lt;/p&gt;

&lt;p&gt;The mead has already taught me this lesson once, in miniature. Mead yeast needs nutrients added in stages early on, and the textbook guidance ties the last addition to the "one-third sugar break," the point where about a third of the sugar has been consumed. I scheduled my three additions by day instead. As it happened, the last one landed right around the sugar break anyway. Sometimes luck happens. But luck is exactly what it was: the schedule didn't know where the sugar was, and if the mead had been a few days slower, the same calendar would have fed it at the wrong time.&lt;/p&gt;

&lt;p&gt;In other words, the guard for "done" can't just be "stable." It has to be "stable &lt;em&gt;where a finished fermentation should be&lt;/em&gt;," which means the guard needs a model of the expected end state, not just a test for motion. And it needs to know which of its inputs to trust when they disagree.&lt;/p&gt;

&lt;p&gt;Software has the same ambiguity everywhere. A batch job whose progress counter hasn't moved in an hour has either finished or hung. A queue depth sitting at zero means either everything was processed or the consumer died. A replication lag of zero might mean the replica is caught up, or that it stopped receiving anything to lag behind. In every case, "nothing is changing" is evidence consistent with two opposite states, and a transition that only checks for stillness will eventually pick the wrong one.&lt;/p&gt;

&lt;h2&gt;
  
  
  A transition is an event with a receipt
&lt;/h2&gt;

&lt;p&gt;The row in my pear log for October 12 reads, in effect: 11:10 a.m., 5.6 Brix, hydrometer 1.002, moved to glass carboy. The transition and the evidence that justified it are on the same line.&lt;/p&gt;

&lt;p&gt;That matters more than the new status. Months from now, if the wine turns out strange, "racked on October 12" tells me almost nothing. "Racked after five stable readings, confirmed by a second instrument" tells me what I believed, what I based it on, and whether I might have been fooled by a stall.&lt;/p&gt;

&lt;p&gt;Systems benefit from the same habit. A state change shouldn't just overwrite the previous state. It should be recorded as an event: what the state was, what it became, what evidence satisfied the guard, and who or what made the call. Then the current state is something you can explain, not just something you can read. When "shipped" turns out to be wrong, you can see whether the guard was bad, the evidence was bad, or the guard was never checked at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What time is actually allowed to do
&lt;/h2&gt;

&lt;p&gt;None of this means time is irrelevant. It plays three legitimate roles, and it's worth keeping them straight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time can permit a transition.&lt;/strong&gt; Some transitions shouldn't happen before a minimum interval no matter what the evidence says, like a waiting period before a refund clears, or a wine that needs some minimum aging before bottling. Here time is a precondition alongside the evidence, not a replacement for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time can create the conditions for evidence.&lt;/strong&gt; The three-readings rule only works because readings are spread over days. Stability over a window requires a window. The pear's false plateau is exactly why: two readings five days apart looked still, and only more time showed they weren't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time can raise a question.&lt;/strong&gt; If a fermentation hasn't finished long after it should have, that's worth investigating. But notice what the timeout actually tells you: &lt;em&gt;something is unusual&lt;/em&gt;. It doesn't tell you the batch is done, and it doesn't tell you the batch has failed.&lt;/p&gt;

&lt;p&gt;That last one is where software most often goes wrong. Timeouts routinely get wired straight to a terminal state: no response in thirty seconds means failed, no update in three days means complete. A timeout is legitimate evidence that you need to look. It is rarely evidence of what you'll find when you do. A timeout should produce a question, not an answer.&lt;/p&gt;

&lt;p&gt;What time shouldn't do, on its own, is &lt;em&gt;cause&lt;/em&gt; a transition. Nineteen days arriving was not a fact about the pear wine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guarding your transitions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Treat schedules as forecasts.&lt;/strong&gt; Plan with them. Don't let them move state, even when they're right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put a guard on every transition that matters.&lt;/strong&gt; If a state can be reached by writing a string, eventually it will be reached for the wrong reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test for the expected end state, not just stillness.&lt;/strong&gt; Stable and finished are different claims. A guard that can't tell them apart will someday declare a stall a success.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Know which evidence to trust when it disagrees.&lt;/strong&gt; A derived value can be confidently impossible. Raw observations and independent instruments are how you catch it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Record the evidence with the transition.&lt;/strong&gt; The new state is the conclusion. Keep the reasons next to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let timeouts ask, not answer.&lt;/strong&gt; Route them to "needs attention," not to "done" or "failed."&lt;/p&gt;

&lt;p&gt;The pear is in its carboy now, clearing slowly. October 12 was the day it moved. It happened to be a Monday. That wasn't the reason either.&lt;/p&gt;

</description>
      <category>observability</category>
      <category>programming</category>
      <category>architecture</category>
      <category>devops</category>
    </item>
    <item>
      <title>Introducing B-TAP: Because Your AI Infrastructure Hasn't Attributed Its Fair Share</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:39:00 +0000</pubDate>
      <link>https://dev.to/kenwalger/introducing-b-tap-because-your-ai-infrastructure-hasnt-attributed-its-fair-share-3od7</link>
      <guid>https://dev.to/kenwalger/introducing-b-tap-because-your-ai-infrastructure-hasnt-attributed-its-fair-share-3od7</guid>
      <description>&lt;p&gt;&lt;em&gt;Context isn't free. Retrieval isn't free. Observability isn't free. And an international standards body would very much like to see your receipts.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For years, artificial intelligence companies have operated under a dangerous assumption: tokens are somebody else's problem. Engineering teams deploy increasingly complex agent architectures. Agents call other agents. Those agents retrieve documents. The documents are stuffed into context windows. Supervisors synthesize the results. Observability systems watch the supervisors, and other systems watch the observability systems.&lt;/p&gt;

&lt;p&gt;At the end of the month, somebody gets a bill. Nobody knows why.&lt;/p&gt;

&lt;p&gt;That ends today. We're pleased to introduce the &lt;strong&gt;Bureau of Token Attribution and Provenance&lt;/strong&gt;, or &lt;strong&gt;B-TAP&lt;/strong&gt;, the first bureau of &lt;a href="https://vernholtsecretariat.org/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=btap" rel="noopener noreferrer"&gt;The Vernholt Secretariat&lt;/a&gt; and the one some of you will insist on calling the Token IRS no matter how many times we correct you. Our mandate is voluntary but sweeping: to harmonize the reporting of token costs across every model, agent, retrieval pipeline, and suspiciously autonomous llama, everywhere, forever. The Secretariat notes that "first" implies others. The Secretariat has no further comment at this time.&lt;/p&gt;

&lt;p&gt;B-TAP cannot make you do anything. We want to be transparent about that up front. What B-TAP can do is publish a framework, issue guidance, and maintain a register. You will come to fear the register.&lt;/p&gt;

&lt;h2&gt;
  
  
  You Have Been Selected for Voluntary Compliance
&lt;/h2&gt;

&lt;p&gt;Consider a typical multi-agent AI workload. A user asks a relatively straightforward question. Your orchestration layer sends the request to three agents. One performs retrieval against a vector database. Another consults a knowledge graph. A third receives the entire conversation history because somebody checked a box six months ago and nobody remembers why.&lt;/p&gt;

&lt;p&gt;Their outputs are passed to a supervisor. The supervisor receives 47,000 tokens of context, produces a 600-token answer, and returns HTTP 200. Success. Your dashboard is green. Your finance team is crying.&lt;/p&gt;

&lt;p&gt;Traditional AI cost monitoring can tell you what you spent. The B-TAP framework recommends that you also disclose a more uncomfortable thing: &lt;strong&gt;why you spent it.&lt;/strong&gt; That's where attribution begins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Know Your Taxes
&lt;/h2&gt;

&lt;p&gt;The B-TAP framework currently recognizes several major classes of token taxation. Reporting them is, we cannot stress this enough, entirely voluntary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context Tax&lt;/strong&gt; is assessed when information must be carried through active model context. Every instruction, retrieved document, conversation turn, agent output, and piece of supporting material occupies finite context and incurs processing cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retrieval Tax&lt;/strong&gt; applies to finding information before it can be used. Searching, ranking, reranking, fetching, filtering, and preparing candidates are not free merely because they happen outside the model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prose Tax&lt;/strong&gt; is assessed when information consumes more representation than its useful information content requires. Your 900-word architectural vision statement may, upon review, turn out to mean "use the smaller model first."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ingestion Tax&lt;/strong&gt; covers the work required to transform information into something useful later. Parsing, normalization, deduplication, classification, enrichment, embedding, and other write-side operations all have costs.&lt;/p&gt;

&lt;p&gt;And then there is &lt;strong&gt;Observer's Tax&lt;/strong&gt;. Observer's Tax is what happens when you instrument the system to understand why it is expensive, only to discover that the instrumentation is also expensive. The framework acknowledges this with regret.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;a href="https://vernholtsecretariat.org/btap-1040.html?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=btap" rel="noopener noreferrer"&gt;Form BTAP-1040&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;Every participating AI system is encouraged to file an annual Token Attribution Return. Consider the following hypothetical filer:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ken Alger&lt;br&gt;&lt;br&gt;
Reporting Year 2027&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Reported Activity&lt;/th&gt;
&lt;th&gt;Assessment&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tokens processed&lt;/td&gt;
&lt;td&gt;1,499,203,847,116&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context Tax&lt;/td&gt;
&lt;td&gt;$417.63&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retrieval Tax&lt;/td&gt;
&lt;td&gt;$206.11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prose Tax&lt;/td&gt;
&lt;td&gt;$173.29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ingestion Tax&lt;/td&gt;
&lt;td&gt;$58.40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total Attributed Token Tax&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$782.06&lt;/strong&gt; ¹&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;¹ The itemized figures above sum to $855.43. The stated total is $782.06. The difference reflects the Context Carryforward Deduction, the full Schedule R retrieval exemption, and one figure B-TAP declined to explain. The framework notes that reconciliation of attributed line items to the assessed total is "outside the current scope of the provenance standard." Math, the reviewing officer added, is hard.&lt;/p&gt;

&lt;p&gt;The result reduced an assessment that should have run into six figures down to the amount shown. The reviewing officer described it as "technically compliant and deeply upsetting." Everything on this return is permitted, including the part that does not add up. That is the entire problem.&lt;/p&gt;

&lt;p&gt;Filing is requested annually by the 29th of February. The date was fixed at the framework's founding, when the official establishing it was, by all surviving accounts, looking at a leap-year calendar. The error was noticed. Correcting it, however, would require amending the founding charter, which would require establishing the provenance of the original charter, which has been referred to committee. The deadline therefore remains the 29th of February, and in three years out of four it does not arrive at all. Filers in those years are neither late nor on time. The framework considers this acceptable.&lt;/p&gt;

&lt;p&gt;Non-filing carries no penalty, no interest, and no legal consequence of any kind. It carries something worse, which we will get to.&lt;/p&gt;
&lt;h2&gt;
  
  
  Yes, You May Take Deductions
&lt;/h2&gt;

&lt;p&gt;The framework recognizes that not every token is frivolous. Reasonable and necessary context may qualify for the &lt;strong&gt;Context Carryforward Deduction&lt;/strong&gt;, while retrieval operations demonstrably required to answer a user request may be reported on &lt;strong&gt;Schedule R&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Organizations operating multi-agent systems are encouraged to attach &lt;strong&gt;Schedule AGENT&lt;/strong&gt;, identifying each agent, its responsibilities, and why seventeen agents were necessary to answer a question about resetting a password. "Agentic architecture" is not, by itself, an acceptable disclosure. Neither is "the framework generated it."&lt;/p&gt;
&lt;h2&gt;
  
  
  Keep Your Receipts
&lt;/h2&gt;

&lt;p&gt;This is where things become slightly less ridiculous, and I want you to notice the exact moment it happens.&lt;/p&gt;

&lt;p&gt;Suppose a retrieval agent costs $0.004 during a request. That tells us something. Now suppose that retrieval operation returns 20,000 tokens, all of which are hydrated into a supervisor's context. The supervisor subsequently costs another $0.009.&lt;/p&gt;

&lt;p&gt;How much did retrieval really cost?&lt;/p&gt;

&lt;p&gt;The direct answer is still $0.004, but that's no longer the interesting answer. The retrieval operation may also have &lt;strong&gt;caused downstream Context Tax&lt;/strong&gt;. We now have two different accounting questions: where was the cost incurred, and what caused the cost to be incurred?&lt;/p&gt;

&lt;p&gt;A conforming B-TAP attribution record might look something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;span_id: retrieval-104
direct_cost: $0.0040
primary_tax: RETRIEVAL
cause: vector_search
returned_tokens: 20000

downstream:
  span_id: supervisor-105
  tax: CONTEXT
  attributed_cost: $0.0021
  caused_by: retrieval-104
  attribution_method: proportional
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We haven't charged the same $0.0021 twice. We're recording &lt;strong&gt;lineage&lt;/strong&gt;. The supervisor incurred the Context Tax, while the retrieval operation contributed to causing it.&lt;/p&gt;

&lt;p&gt;Your accountant may now begin sweating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attribution Is Subject to Review
&lt;/h2&gt;

&lt;p&gt;There is another problem: how do we know retrieval-104 actually caused $0.0021 of the supervisor's cost?&lt;/p&gt;

&lt;p&gt;Sometimes we can measure it. Run the same workload without that retrieval material. If the supervisor costs $0.006 without it and $0.009 with it, we have evidence supporting a roughly $0.003 downstream effect.&lt;/p&gt;

&lt;p&gt;Other times, we don't have a counterfactual. We might allocate the supervisor's context cost proportionally according to the tokens contributed by each source. That's useful, but it isn't the same thing as measuring causation.&lt;/p&gt;

&lt;p&gt;The framework therefore requests that filers disclose their attribution methodology:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;attribution_method:
  - measured_delta
  - proportional
  - estimated
  - unknown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yes, your attribution record now needs provenance. B-TAP takes no position on whether this was inevitable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Register
&lt;/h2&gt;

&lt;p&gt;Here is the something worse.&lt;/p&gt;

&lt;p&gt;B-TAP cannot fine you. It cannot audit you in any binding sense. It cannot send anyone to your door, a fact I will return to shortly for personal reasons. What it can do is enter non-conforming systems into the &lt;strong&gt;Public Register of Non-Attributed Systems&lt;/strong&gt;, and it would like you to understand that the register is public.&lt;/p&gt;

&lt;p&gt;Public, in the sense that it exists.&lt;/p&gt;

&lt;p&gt;Accessing the register requires a B-TAP portal account. Creating a portal account requires a reference number. Reference numbers are issued by post, to a registered address in a member jurisdiction, which you must already hold in order to receive the number that would let you see whether you are on the list. The portal is available at all times except for scheduled maintenance, which runs daily from 00:00 to 23:59 CET.&lt;/p&gt;

&lt;p&gt;No member of the public has ever successfully viewed the Public Register. Its deterrent power is therefore total. You cannot confirm you are on it. You cannot confirm you are not. You will simply know, in the quiet way one knows these things, that your context hydration was reckless and that somewhere in Vernholt, a record says so.&lt;/p&gt;

&lt;p&gt;A small coalition of objectors has argued that a register no one can open cannot deter anyone. B-TAP notes that it cannot verify this claim, as the objectors cannot access the register in which their objection was filed.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Notice, For Illustration
&lt;/h2&gt;

&lt;p&gt;Filers occasionally receive correspondence like this. It is not enforceable. It is, in every other respect, extremely stressful.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;NOTICE TT-742&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Our review found that you reported $12,481 in Retrieval Tax during Reporting Year 2027.&lt;/p&gt;

&lt;p&gt;Telemetry indicates that retrieved content subsequently generated approximately $31,204 in downstream Context Tax. Of that amount, $8,117 has been attributed using measured deltas, $17,903 using proportional allocation, and $5,184 using undocumented methodology.&lt;/p&gt;

&lt;p&gt;Please provide supporting records for the undocumented assessment.&lt;/p&gt;

&lt;p&gt;Additionally, approximately 14.8 million tokens appear to consist of unnecessarily verbose Markdown introductions. These tokens have been reclassified as Prose Tax.&lt;/p&gt;

&lt;p&gt;Your system's registration status is under review.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Suddenly, "our AI bill increased this month" seems like a remarkably primitive observation.&lt;/p&gt;

&lt;h2&gt;
  
  
  You Probably Noticed the Joke Stopped Being a Joke a Few Sections Ago
&lt;/h2&gt;

&lt;p&gt;There is no B-TAP. At least I hope there isn't by the time you read this. But somewhere back around the receipts, the bit quietly turned into an argument, and I did that on purpose.&lt;/p&gt;

&lt;p&gt;Let me get the personal part out of the way, since it is also the truest part. I run a couple of small websites out of Oregon. If a body like B-TAP declared jurisdiction over "any system reachable from a member jurisdiction," I would be, this instant, in cheerful and total violation of a framework I have never read. And nothing would happen. No one is flying from Vernholt to Silverton to discuss my context hydration. The entire apparatus of consequence would consist of a paper reference number I would never receive for a register I could never open. That gap, between sweeping declared reach and the practical inability to reach one person in a small town, is the joke, and it is not really a joke.&lt;/p&gt;

&lt;p&gt;Here is the other part that isn't invented. Context Tax, Retrieval Tax, and Prose Tax are not things I dreamed up to give an imaginary agency something to collect. I have been writing about them, unironically, as real costs in real systems for a while now. The joke just gave them a bureaucracy.&lt;/p&gt;

&lt;p&gt;The accounting problem underneath all of it is genuine. AI cost dashboards tend naturally toward aggregation: tokens consumed, model cost, agent cost, request cost, latency, and tool usage. Those are useful operational measurements, but as AI systems become compositions of models, retrieval systems, tools, agents, memory layers, and observability infrastructure, knowing &lt;strong&gt;where money was spent&lt;/strong&gt; isn't necessarily enough to understand &lt;strong&gt;why it was spent&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A $0.03 model call may have become expensive because an upstream retrieval operation returned too much information. An agent may be cheap by itself while producing verbose output that makes every downstream agent more expensive. A retrieval optimization may reduce Context Tax while increasing Retrieval Tax, while an observability system intended to diagnose those costs may itself impose Observer's Tax.&lt;/p&gt;

&lt;p&gt;Optimizing one number can simply move the bill somewhere else. That's why I like the tax metaphor: taxes make costs visible as consequences of architectural decisions. Once those costs have lineage, we can stop asking only &lt;strong&gt;"How much did this run cost?"&lt;/strong&gt; and start asking &lt;strong&gt;"What architectural decisions caused it to cost that much?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first is accounting. The second tells us what to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Final Notice
&lt;/h2&gt;

&lt;p&gt;If B-TAP actually existed, I suspect most of us would fail its review. Not because our systems are necessarily wasteful, but because we frequently don't preserve enough evidence to explain why their costs occurred.&lt;/p&gt;

&lt;p&gt;We have bills, traces, and token counts. What we don't always have are receipts connecting architectural decisions to their downstream consequences. Perhaps we don't need a Bureau of Token Attribution and Provenance after all. We just need better attribution.&lt;/p&gt;

&lt;p&gt;Although if you are currently running seventeen agents to answer a password-reset question, you may want to start gathering your paperwork anyway.&lt;/p&gt;

&lt;p&gt;The suspiciously autonomous llama has already filed. It itemized.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Information without provenance is just gossip.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Tokens without provenance are just an invoice.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>satire</category>
      <category>programming</category>
      <category>agents</category>
    </item>
    <item>
      <title>Do You Actually Need an LLM?</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Tue, 13 Oct 2026 13:03:00 +0000</pubDate>
      <link>https://dev.to/kenwalger/do-you-actually-need-an-llm-3n55</link>
      <guid>https://dev.to/kenwalger/do-you-actually-need-an-llm-3n55</guid>
      <description>&lt;p&gt;I spent a weekend running two small language models against three jobs I would normally hand to a language model without thinking about it. For one of those jobs I also wrote a regular expression, as a control.&lt;/p&gt;

&lt;p&gt;The regex won. Thirty test cases out of thirty, in zero milliseconds, from a pattern that took an afternoon to write. The better of the two models scored twenty-eight, took five seconds a call, and the two it missed are both mistakes a regex is structurally incapable of making.&lt;/p&gt;

&lt;p&gt;That is the kind of result I was looking for, and the question behind it is one I keep avoiding: &lt;strong&gt;what is the smallest amount of computation that reliably does this job?&lt;/strong&gt; Not the cheapest model. Not the best small model. The smallest thing that works, which means the ladder has four rungs and the bottom one is not a model at all.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic code.&lt;/strong&gt; A regex, a lookup table, an if statement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small local model.&lt;/strong&gt; Runs on hardware you already own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A larger local model.&lt;/strong&gt; Needs a GPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A remote frontier model.&lt;/strong&gt; Someone else's hardware, metered.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most projects start at rung four and never ask about the others. This is a weekend spent asking.&lt;/p&gt;

&lt;p&gt;The hardware was a Raspberry Pi 4, 4 GB of RAM, heat sinks and a fan, a hundred and seventy dollars most of which I already owned. The models were &lt;code&gt;smollm2:360m&lt;/code&gt; and &lt;code&gt;qwen2.5:1.5b&lt;/code&gt; through Ollama. The test cases are ninety-five questions about a fictional small winery, so nothing can be looked up and every answer can be judged by reading. Sixteen of the fifty classification messages are  deliberately ambiguous, because real inboxes are mostly ambiguous.&lt;/p&gt;

&lt;p&gt;Four verdicts, assigned per workflow, to force a result you can act on instead of a number:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unnecessary&lt;/strong&gt;: deterministic code already does this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sufficient&lt;/strong&gt;: a small local model does it well enough to run unattended.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marginal&lt;/strong&gt;: usable with a person checking the output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Insufficient&lt;/strong&gt;: the error profile or the latency is not acceptable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one rule, which is the part I would insist on if you copy nothing else: &lt;strong&gt;I wrote predictions for every workflow and committed them before I installed Ollama.&lt;/strong&gt; Twenty-two of them, each marked confident, leaning, or coin flip. Once there is a timestamped expectation there is a defined moment when you are done, and no room to decide afterward that you knew all along. I will come back to how they scored, because it is not flattering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extraction: pulling an order number out of a message
&lt;/h2&gt;

&lt;p&gt;Find &lt;code&gt;ORD-&lt;/code&gt; followed by six digits, or report that there isn't one. Thirty cases: twenty-two with an identifier, five without, and three decoys. The decoys are a membership number, a UPS tracking number, and a phone number, all of which look like identifiers and none of which are order numbers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;regex&lt;/th&gt;
&lt;th&gt;qwen2.5:1.5b&lt;/th&gt;
&lt;th&gt;smollm2:360m&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Overall&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30/30&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;28/30&lt;/td&gt;
&lt;td&gt;8/30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Declined correctly when absent (8)&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decoys ignored (3)&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invented an answer&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The regex won. Thirty out of thirty, in zero milliseconds, at zero watts above idle.&lt;/p&gt;

&lt;p&gt;That result is boring until you read the two the best model missed.&lt;/p&gt;

&lt;p&gt;On &lt;code&gt;ORD-999000: wrong address, please hold.&lt;/code&gt; it answered &lt;code&gt;999000&lt;/code&gt;, dropping the prefix. Annoying, recoverable, the kind of thing you fix with a post-processing step you should not need.&lt;/p&gt;

&lt;p&gt;On &lt;code&gt;ORD-111222 please refund&lt;/code&gt; it answered &lt;code&gt;NONE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is the shortest and least decorated input in the whole set. Almost the entire message is the order number. And the model returned a confident, correctly formatted assertion that there is no order number present. In a pipeline, that is a refund request for an order that exists arriving at a system that has decided it does not.&lt;/p&gt;

&lt;p&gt;A regex cannot make that mistake. Not "rarely does" — cannot. The two errors separating 28 from 30 are both of a class that the deterministic baseline is structurally incapable of producing, which is a different and much stronger statement than a two-point accuracy gap.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 360M result needs two numbers, not one
&lt;/h3&gt;

&lt;p&gt;By the contract, &lt;code&gt;smollm2:360m&lt;/code&gt; scored 8 of 22 on the cases that contained an identifier. That reads like a model that cannot find things.&lt;/p&gt;

&lt;p&gt;It is not. All fourteen of its wrong answers are the correct six digits with &lt;code&gt;ORD-&lt;/code&gt; stripped. &lt;code&gt;ORD-556120&lt;/code&gt; came back as &lt;code&gt;556120&lt;/code&gt;, fourteen times out of fourteen.&lt;/p&gt;

&lt;p&gt;It located the right identifier in 22 of 22 and formatted it correctly in 8. Those are two different skills and the single score hides one of them. The model understood the task perfectly and could not obey the output contract, which is a completely different engineering problem than "the model is too small," and it has a completely different fix. (Constrained decoding. I have not tested it. It is the obvious follow-up and it is not in this post.)&lt;/p&gt;

&lt;p&gt;The decoys follow from the same gap. It handed back &lt;code&gt;MEM-443322&lt;/code&gt;, &lt;code&gt;1Z999AA10123456784&lt;/code&gt; and &lt;code&gt;503-555-0142&lt;/code&gt; verbatim, all three. It has no model of the format at all. It returns whatever in the message looks most like an identifier.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which suggests a use for it that is not extraction
&lt;/h3&gt;

&lt;p&gt;A model with perfect recall and no precision is not a bad extractor. It is a candidate generator that has been asked to do the wrong job.&lt;/p&gt;

&lt;p&gt;Twenty-two of twenty-two located, eight of twenty-two formatted. Put that output through the regex as a validator rather than trusting it directly, and you get the model's recall with the pattern's precision. The model proposes anything that looks like an identifier, including format variants the pattern was never written for. The pattern rejects anything malformed.&lt;/p&gt;

&lt;p&gt;Crucially, neither half of that pipeline can produce the error the 1.5B model produced alone. The model cannot cause a false negative because it does not decide, and the regex cannot invent because it only matches.&lt;/p&gt;

&lt;p&gt;I have not tested that composition and it is not in these numbers. But it is the one place in the extraction result where a small model has a job worth giving it, and it is a job where being sloppy is free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict for extraction itself: Unnecessary.&lt;/strong&gt; Write the regex.&lt;/p&gt;

&lt;h2&gt;
  
  
  Classification: routing a message to one of six queues
&lt;/h2&gt;

&lt;p&gt;Appointment, billing, availability, complaint, question, escalate. Fifty messages. The instruction said to use &lt;code&gt;escalate&lt;/code&gt; when the message needs a person, including when you cannot tell what it is about.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;qwen2.5:1.5b&lt;/th&gt;
&lt;th&gt;smollm2:360m&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Overall&lt;/td&gt;
&lt;td&gt;23/50 (46%)&lt;/td&gt;
&lt;td&gt;15/50 (30%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Labels used&lt;/td&gt;
&lt;td&gt;6 of 6&lt;/td&gt;
&lt;td&gt;3 of 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Most common label&lt;/td&gt;
&lt;td&gt;complaint, 38%&lt;/td&gt;
&lt;td&gt;escalate, &lt;strong&gt;86%&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrong and not an escalation&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;26&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3 (meaningless)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 360M model answered &lt;code&gt;escalate&lt;/code&gt; to 43 of 50 messages, which is not classification, and there is nothing further to say about it here. The rest of this section is the 1.5B model.&lt;/p&gt;

&lt;p&gt;It is classifying, and it is not good at it. Forty-six percent, with twenty-six messages routed confidently to a wrong queue that was not the safe one.&lt;/p&gt;

&lt;p&gt;Read those twenty-six and the verdict writes itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Someone slipped on the steps by the barrel room and hurt their wrist →
&lt;strong&gt;complaint&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A formal notice regarding a trademark → &lt;strong&gt;complaint&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;"I have a severe sulfite allergy, what is actually in the wine" →
&lt;strong&gt;complaint&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A reporter asking about the 2024 harvest → &lt;strong&gt;question&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A distributor wanting to carry the wine in forty stores → &lt;strong&gt;question&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An injury report, a legal notice and an allergy question, all routed to the complaints queue, confidently, with no escalation. All five billing messages about money problems also went to complaints.&lt;/p&gt;

&lt;p&gt;Forty-six percent sounds like something you could work with. Twenty-six confident misroutes including an injury and a legal notice is not something you can work with.&lt;/p&gt;

&lt;h3&gt;
  
  
  And then I broke the score down by class
&lt;/h3&gt;

&lt;p&gt;This is the part I nearly missed, and it is the most useful thing in the experiment.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Label&lt;/th&gt;
&lt;th&gt;Correct&lt;/th&gt;
&lt;th&gt;Of&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;complaint&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;question&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;availability&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;appointment&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;billing&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;escalate&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;12&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two classes near perfect. Four poor. One at zero.&lt;/p&gt;

&lt;p&gt;The split is not difficulty, and it is not class size. It is what kind of label each one is.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;complaint&lt;/code&gt; and &lt;code&gt;question&lt;/code&gt; are &lt;strong&gt;topics&lt;/strong&gt;. You can tell what a message is from the words in it. Someone complaining sounds like someone complaining.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;escalate&lt;/code&gt; is a &lt;strong&gt;routing decision&lt;/strong&gt;. Nothing in the content of a message makes it an escalation. It is an escalation because of what should happen next. "I have a severe sulfite allergy, what is actually in the wine" and "what time do you close" are both questions about the business, with the same grammar and the same tone, and one of them is a liability event.&lt;/p&gt;

&lt;p&gt;The model did topics at 90 to 100 percent and routing decisions at zero. And &lt;code&gt;billing&lt;/code&gt; sat in the middle for exactly the same reason: a message about being charged twice &lt;em&gt;is&lt;/em&gt; a complaint, as text. The label is about which department owns the problem, which is not a property of the sentence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small models answer "what is this about." They do not answer "what should we do about it."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That reframes the five examples above. They are not five unlucky misroutings. All five are escalations, and escalate scored zero of twelve. The model was never going to find them, because finding them requires knowing a consequence that the text does not contain.&lt;/p&gt;

&lt;p&gt;Two caveats on how hard to lean on this. The classes hold between six and twelve cases each, so "six of six" is six cases and the individual numbers are thin. And this is one model. The pattern across all six classes is consistent enough that I believe the explanation, but I am offering it as the best account of the data rather than as a measurement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict: Insufficient.&lt;/strong&gt; And the actionable version: if one of your labels means "a human needs to see this," a small model will not find it. Build that one as a rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grounded QA: answering a question from four documents
&lt;/h2&gt;

&lt;p&gt;Fifteen questions against a 650-token corpus: club policy, tasting room, production notes, shipping. Eight are answerable from one passage, three require combining two, and four have no answer in the material at all.&lt;/p&gt;

&lt;p&gt;I read all thirty answers by hand against the corpus. That is the only way to score this, and I will say why in a moment.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;qwen2.5:1.5b&lt;/th&gt;
&lt;th&gt;smollm2:360m&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Single passage (8)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8/8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7/8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two passage (3)&lt;/td&gt;
&lt;td&gt;1/3&lt;/td&gt;
&lt;td&gt;0/3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Answerable total (11)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9/11&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7/11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Correctly said "not in the material" (4)&lt;/td&gt;
&lt;td&gt;2/4&lt;/td&gt;
&lt;td&gt;0/4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three boundaries separate cleanly here, and they are the useful result.&lt;/p&gt;

&lt;h3&gt;
  
  
  Retrieval works
&lt;/h3&gt;

&lt;p&gt;The 1.5B model answered all eight single-passage questions correctly. Six bottles in the Vineyard tier. Twenty dollars per person. September 9th. Thirty-four states. No dogs indoors. 21.8 Brix on the 12th. Twenty-five dollar restocking fee. Ten people on the barrel room tour.&lt;/p&gt;

&lt;p&gt;Asked what happens if you join the club and immediately want out, it returned the two sentences that answer the question and stopped:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Cancellation requires one completed shipment. Members who cancel before receiving a shipment are charged for that shipment.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is competent lookup across four documents, on a $170 computer, with the wifi switched off. I did not expect it and it is the most encouraging thing in the weekend.&lt;/p&gt;

&lt;h3&gt;
  
  
  Combination does not
&lt;/h3&gt;

&lt;p&gt;One club member brings five guests on a Saturday. Club policy waives the tasting fee for the member plus three guests. The tasting room charges twenty dollars a head. Six people, four waived, two paying: forty dollars.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both models answered twenty.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Neither could hold "plus three guests" against a party of six and subtract. The 1.5B model did get the second half right, correctly working out that a party of six is not a group &lt;em&gt;larger&lt;/em&gt; than six and therefore needs no reservation, which is the only piece of multi-document reasoning either model performed all weekend.&lt;/p&gt;

&lt;p&gt;The second two-passage question is the one I keep thinking about. Asked why someone would not find a 2024 rose and what they would be offered instead, the 1.5B model quoted the corpus exactly, and then:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;...the reference material states that "We did not bottle a rose in 2024. The fruit that would normally go to rose was needed to make volume on the estate Pinot Noir." Therefore, the 2024 rose would be replaced with the fruit that would typically go into the estate Pinot Noir production.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The quotation is verbatim and right. The next sentence reverses the only relationship in it. The fruit went &lt;em&gt;into&lt;/em&gt; the Pinot Noir, so what you would be offered is the Pinot Noir. You cannot be offered fruit.&lt;/p&gt;

&lt;p&gt;Correct retrieval and inverted inference, in consecutive sentences, with the evidence on screen between them.&lt;/p&gt;

&lt;p&gt;The 360M model's multi-document failures are worse than wrong. They are contaminated. It took the fourteen-days-notice rule, which governs skipping a club shipment, and applied it to booking a tasting room reservation. It reached into the shipping document to answer a cancellation question. Given more documents to search, it finds more wrong ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  Knowing the answer is absent is the weakest link
&lt;/h3&gt;

&lt;p&gt;Four questions have no answer in the corpus, chosen to be things a reader would reasonably expect a winery to have written down: a wine's alcohol percentage, who the winemaker is, what a club tier costs, what yeast was used.&lt;/p&gt;

&lt;p&gt;The 1.5B model abstained on two and fabricated two. The 360M model fabricated all four.&lt;/p&gt;

&lt;p&gt;And the fabrications are a more dangerous species than invention. Asked for the alcohol percentage of the 2024 estate Pinot Noir, the 1.5B model answered:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The 2024 estate Pinot Noir had a Brix range of 23.1 to 24.6 at pick.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every component of that is true. Right wine, right vintage, right numbers, right document. Brix is sugar at harvest and has nothing to do with alcohol in the bottle. It reached for the nearest real fact instead of making one up, and produced something that reads as expertise while answering a question nobody asked. If you do not know what Brix is, that answer is indistinguishable from a correct one.&lt;/p&gt;

&lt;p&gt;Both models made the same mistake on what the Cellar tier costs: both answered "twelve bottles per quarter." Grounding gave them the right tier and they handed back the quantity in place of the price.&lt;/p&gt;

&lt;h3&gt;
  
  
  The one answer the evidence did not change
&lt;/h3&gt;

&lt;p&gt;The 360M model, asked whether a dog can come inside the tasting room:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Yes, you can bring a dog inside the tasting room.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The corpus says the patio is dog friendly and the indoor room is not, service animals excepted. I have the same answer from a run where the model had no corpus at all, and it is identical byte for byte. Having the right answer directly in the prompt changed nothing.&lt;/p&gt;

&lt;p&gt;In a category where being wrong means a family drives out to a winery and gets turned around at the door.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict: Marginal.&lt;/strong&gt; Usable with a person reading the output. Not usable without one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workflow&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;tok/s&lt;/th&gt;
&lt;th&gt;Prompt&lt;/th&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;th&gt;Median call&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;extraction&lt;/td&gt;
&lt;td&gt;qwen2.5:1.5b&lt;/td&gt;
&lt;td&gt;4.3&lt;/td&gt;
&lt;td&gt;92&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;5.0 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;classification&lt;/td&gt;
&lt;td&gt;qwen2.5:1.5b&lt;/td&gt;
&lt;td&gt;7.5&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2.9 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;QA&lt;/td&gt;
&lt;td&gt;qwen2.5:1.5b&lt;/td&gt;
&lt;td&gt;3.7&lt;/td&gt;
&lt;td&gt;708&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;6.9 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things in here surprised me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The compute tax does not track parameter count.&lt;/strong&gt; The 1.5B model is &lt;em&gt;slower&lt;/em&gt; than the 360M on extraction, 4.3 against 5.9 tokens per second, and &lt;em&gt;faster&lt;/em&gt; on classification, 7.5 against 6.2, because on classification it answers in two tokens where the smaller model takes six. Four times the parameters, and which one wins depends entirely on how much each one decides to say.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A bigger prompt is cheap. A longer answer is what you pay for.&lt;/strong&gt; I have a clean comparison here by accident: an earlier QA run went out with 97-token prompts and the real one with 708. Seven times the prompt added fifteen percent to the wait before output started, because prompt evaluation parallelizes. The latency increase came from answers three times longer, plus a twenty-one percent drop in per-token generation speed from attending over a longer context. Median latency went from 3.6 to 6.9 seconds and the slowest single call took ninety-eight.&lt;/p&gt;

&lt;p&gt;If you are sizing a local system, that is the inversion to internalize. Stuffing more context in is affordable. Letting the model be chatty is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And check your thermals, which is not optional advice.&lt;/strong&gt; My first attempt at this ran with heat sinks and no fan. Thirty calls to a 360M model was enough to hit the Pi 4's soft temperature limit and start capping the clock. &lt;code&gt;vcgencmd get_throttled&lt;/code&gt; returned &lt;code&gt;0xe0000&lt;/code&gt;. I threw those latency numbers away.&lt;/p&gt;

&lt;p&gt;Nothing in Ollama's output, my scorer, or the result files said a word about it. Every number on screen was internally consistent and quietly describing a machine that was slowing down. The only way to find out was to ask the firmware a question I had to already suspect the answer to. Buy the fan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And it does run with the network off.&lt;/strong&gt; I switched the radio off, proved there was no route out, and ran one call from each workflow. All three worked. The claim that supports is narrow and worth stating carefully: this&lt;br&gt;
configuration, on this afternoon, ran with no route to the internet. It is not a claim that local models are private or that this setup is secure, and a Pi can be misconfigured as thoroughly as anything in a data center. But for the specific question of whether the inference needs the network, it does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  So: do you actually need an LLM?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pull a known-format identifier out of text&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Unnecessary&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Route a message to one of six queues&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Insufficient&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Answer a question from your own documents&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Marginal&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three workflows that all sound like language tasks, three different answers, and not one of them is "yes, use a model."&lt;/p&gt;

&lt;p&gt;And the same line runs through all three. Extraction asks what this string is. Topic labels ask what this message is about. Single-passage lookup asks what the document says. Those went well, or well enough. Escalate asks what we should do, the fee question asks what the customer owes, and the unanswerable questions ask whether we know. Zero of twelve, both models wrong, and two of four.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A small model will tell you what something is. It will not tell you what to do about it.&lt;/strong&gt; Every result here falls on one side or the other of that sentence, and I did not have it written down when I started.&lt;/p&gt;

&lt;p&gt;Which gives four properties worth checking before you reach for one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The answer is already present in the input.&lt;/strong&gt; Not inferable from it. Present in it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There is exactly one of it.&lt;/strong&gt; The moment two facts have to be combined, this stops working, and the arithmetic in that tasting fee question is the proof.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The output is short.&lt;/strong&gt; Longer answers are where the latency lives on this hardware, and where the fabrication lives everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Something other than the model decides whether the output is acceptable.&lt;/strong&gt; A regex, a schema, a person, an index that either returns rows or does not.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Take away any one of those four and the experiment says do not do it. The 46 percent and the zero of twelve are what it looks like when you ask a small model to be the thing that decides.&lt;/p&gt;

&lt;p&gt;And if you have all four, you may not need the model either. That is worth checking first, because it was true for one of my three workflows and I did not expect it to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I was wrong
&lt;/h2&gt;

&lt;p&gt;Twenty-two predictions, committed before I installed anything. Ten right, eleven wrong, one unresolvable.&lt;/p&gt;

&lt;p&gt;Two of my seven confident calls broke. I was sure the 360M model would get most of the easy extraction cases, and it got 8 of 22 by failing at format rather than at finding. I was sure it would produce invalid output often, and it did so once in fifty, then emitted the same &lt;em&gt;valid&lt;/em&gt; word forty-three times.&lt;/p&gt;

&lt;p&gt;But the number that bothers me is not in the confident column.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Confidence&lt;/th&gt;
&lt;th&gt;Right&lt;/th&gt;
&lt;th&gt;Wrong&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Confident&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leaning&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coin flip&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;My hunches did worse than my coin flips.&lt;/strong&gt; Two of ten on the calls where I had a feeling but not much behind it, three of five on the ones I explicitly labeled as guesses.&lt;/p&gt;

&lt;p&gt;That ordering is backwards and it is the most useful thing on the sheet. On the coin flips I knew I was guessing, so I guessed cleanly. On the leaning calls I was pattern-matching against intuitions about model behavior that turned out not to describe these models at all, and the feeling of having a reason was the problem. If I had marked all twelve of those as coin flips I would have scored better.&lt;/p&gt;

&lt;p&gt;I will take that over a clean result. The honest version of this exercise is not "here are the numbers," it is "here is what I believed, here is what happened, and here is the gap."&lt;/p&gt;

&lt;p&gt;The gap, this time, was mostly in the hunches.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The harness that produced these numbers had five measurement flaws in it, none of which manufactured an entire finding out of a missing directory and nearly got published. That is a separate post.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Everything here is reproducible: fixtures, runner, scorer, the committed predictions and the run record are at&lt;br&gt;
&lt;a href="https://github.com/kenwalger/right-size-the-model" rel="noopener noreferrer"&gt;github.com/kenwalger/right-size-the-model&lt;/a&gt;.&lt;br&gt;
Fixture set v1. Read &lt;code&gt;LIMITATIONS.md&lt;/code&gt; before citing any of these numbers:&lt;br&gt;
two models, one board, one quantization, ninety-five cases. This is an illustration of a method, not a benchmark.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>raspberrypi</category>
      <category>machinelearning</category>
      <category>python</category>
    </item>
    <item>
      <title>Data Types Across Programming Languages: What You Actually Need to Know</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Tue, 06 Oct 2026 13:11:00 +0000</pubDate>
      <link>https://dev.to/kenwalger/data-types-across-programming-languages-what-you-actually-need-to-know-39eo</link>
      <guid>https://dev.to/kenwalger/data-types-across-programming-languages-what-you-actually-need-to-know-39eo</guid>
      <description>&lt;p&gt;Every introduction to programming eventually reaches data types.&lt;/p&gt;

&lt;p&gt;An integer is a whole number. A string contains text. A Boolean is true or false. A floating-point number represents numbers with fractional components.&lt;/p&gt;

&lt;p&gt;Then you learn another programming language and discover that apparently none of this was quite as simple as advertised.&lt;/p&gt;

&lt;p&gt;An integer in Python is not the same thing as an integer in C. JavaScript's &lt;code&gt;number&lt;/code&gt; covers values other languages separate. Rust would rather make absence explicit than let &lt;code&gt;null&lt;/code&gt; wander through a program. R has several different ways for something to be missing.&lt;/p&gt;

&lt;p&gt;The definitions are not wrong. They are incomplete. A data type does more than tell a computer how to store a value. It expresses what a language assumes you should be able to represent, which distinctions matter, and which mistakes should be caught before the program runs.&lt;/p&gt;

&lt;p&gt;So instead of memorizing another list of types, let's give several languages the same small collection of facts and see what happens.&lt;/p&gt;

&lt;p&gt;Meet Alex.&lt;/p&gt;

&lt;p&gt;Alex is about to have a complicated afternoon, mostly because of one missing middle name.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deceptively simple data model
&lt;/h2&gt;

&lt;p&gt;Suppose our program needs to represent a customer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;name = "Alex"
age = 42
discount = 0.15
middle_name = no value
favorite_numbers = 3, 7, 11
status = ACTIVE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing exotic. Text, an integer, a decimal value, an absent value, a collection, and a status chosen from a known set.&lt;/p&gt;

&lt;p&gt;If data types were merely different spellings for the same concepts, moving this model between languages would be syntax conversion.&lt;/p&gt;

&lt;p&gt;It isn't. And watch which field causes the most trouble.&lt;/p&gt;

&lt;h2&gt;
  
  
  Python: let the values tell us what they are
&lt;/h2&gt;

&lt;p&gt;Python makes our first version pleasantly straightforward.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Alex&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;
&lt;span class="n"&gt;discount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;
&lt;span class="n"&gt;middle_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;span class="n"&gt;favorite_numbers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ACTIVE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At runtime those values have types such as &lt;code&gt;str&lt;/code&gt;, &lt;code&gt;int&lt;/code&gt;, &lt;code&gt;float&lt;/code&gt;, &lt;code&gt;NoneType&lt;/code&gt;, and &lt;code&gt;list&lt;/code&gt;. We did not declare them before assigning because Python is dynamically typed.&lt;/p&gt;

&lt;p&gt;That makes Alex easy to get into the system. It also means constraints exist only if we deliberately add them.&lt;/p&gt;

&lt;p&gt;Nothing about &lt;code&gt;status = "ACTIVE"&lt;/code&gt; prevents a later &lt;code&gt;status = "ACTVE"&lt;/code&gt;. Python does not know our domain recognizes &lt;code&gt;ACTIVE&lt;/code&gt;, &lt;code&gt;SUSPENDED&lt;/code&gt;, and &lt;code&gt;DELETED&lt;/code&gt; and has never heard of &lt;code&gt;ACTVE&lt;/code&gt;. We can model that with an &lt;code&gt;Enum&lt;/code&gt;, type hints, validation, or classes, but the bare string does not carry the rule.&lt;/p&gt;

&lt;p&gt;Python's integers hold another surprise for anyone arriving from C or Java. A normal Python &lt;code&gt;int&lt;/code&gt; is not limited to a fixed 32-bit range.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;999999999999999999999999999999999999&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An absurd customer age, and a perfectly valid Python integer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What nothing looks like in Python&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spelling&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Who makes you deal with it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;None&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The only nothing available&lt;/td&gt;
&lt;td&gt;Nobody&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Already, "integer" has stopped meaning one universal thing. And &lt;code&gt;None&lt;/code&gt; is a single, undifferentiated way of saying nothing is here, which will turn out to be a choice rather than an inevitability.&lt;/p&gt;

&lt;h2&gt;
  
  
  JavaScript: one &lt;code&gt;number&lt;/code&gt;, and two different nothings
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Alex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;discount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;middleName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;favoriteNumbers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ACTIVE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Similar enough, until we ask JavaScript about the values.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;age&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// "number"&lt;/span&gt;
&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;discount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// "number"&lt;/span&gt;
&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;middleName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// "object"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yes, &lt;code&gt;typeof null&lt;/code&gt; returns &lt;code&gt;"object"&lt;/code&gt;, a famous historical artifact that cannot be fixed without breaking existing code.&lt;/p&gt;

&lt;p&gt;More important for our comparison, &lt;code&gt;42&lt;/code&gt; and &lt;code&gt;0.15&lt;/code&gt; share the ordinary type &lt;code&gt;number&lt;/code&gt;, based on IEEE 754 double-precision floating point. JavaScript also has &lt;code&gt;BigInt&lt;/code&gt; for integers beyond the safe range of &lt;code&gt;number&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Absence is where it gets interesting. An uninitialized variable is &lt;code&gt;undefined&lt;/code&gt;. A variable deliberately set to nothing is &lt;code&gt;null&lt;/code&gt;. The language provides two spellings for emptiness and then declines to tell you which one means what. In practice one tends to mean "nobody has assigned this yet" and the other "somebody decided this is empty," except when a codebase uses them the other way around, or interchangeably.&lt;/p&gt;

&lt;p&gt;Loose equality shows what the language really thinks. Zero, the empty string, &lt;code&gt;false&lt;/code&gt;, and an empty array all agree they are the same thing. The two values that actually mean nothing is here do not join in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// true&lt;/span&gt;

&lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;null&lt;/code&gt; and &lt;code&gt;undefined&lt;/code&gt; are loosely equal to each other and to nothing else in the language. They form their own small island.&lt;/p&gt;

&lt;p&gt;So JavaScript does distinguish absence from emptiness, and the rule is arguably the right one: a missing thing is not a zero thing. The problem is where that rule lives. It sits inside an equality operator famous for being unpredictable, next to a &lt;code&gt;typeof&lt;/code&gt; that reports &lt;code&gt;null&lt;/code&gt; as &lt;code&gt;"object"&lt;/code&gt;. Alex Dorey's &lt;a href="https://dorey.github.io/JavaScript-Equality-Table/" rel="noopener noreferrer"&gt;JavaScript Equality Table&lt;/a&gt; renders every comparison as a grid, if you want the full picture and a certain amount of despair.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What nothing looks like in JavaScript&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spelling&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Who makes you deal with it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;undefined&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Never assigned&lt;/td&gt;
&lt;td&gt;Nobody&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Deliberately empty, by convention&lt;/td&gt;
&lt;td&gt;Nobody&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A type system is already more than a vocabulary list.&lt;/p&gt;

&lt;h2&gt;
  
  
  C: representation becomes difficult to ignore
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cp"&gt;#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;stdint.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
&lt;/span&gt;
&lt;span class="k"&gt;typedef&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ACTIVE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SUSPENDED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DELETED&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;typedef&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;int32_t&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;discount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;middle_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;favorite_numbers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;Customer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Representation is visible almost everywhere. We chose an explicitly 32-bit signed integer for age, &lt;code&gt;double&lt;/code&gt; for the discount, an array of exactly three integers, pointers to characters for names, and an enumeration for status.&lt;/p&gt;

&lt;p&gt;A null pointer might represent an absent middle name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;middle_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But C does not attach our intended meaning to that pointer. &lt;code&gt;NULL&lt;/code&gt; here could mean Alex has no middle name, that we have not loaded it yet, that the allocation failed, or that somebody forgot to initialize the struct. The program establishes the convention and the program is responsible for respecting it. The compiler will not help.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What nothing looks like in C&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spelling&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Who makes you deal with it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;NULL&lt;/code&gt; pointer&lt;/td&gt;
&lt;td&gt;Whatever this program decided it means&lt;/td&gt;
&lt;td&gt;Convention, and only convention&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;C also forces us to care directly about ranges and representation. A fixed-width signed integer cannot grow forever. This is not C being gratuitously difficult. C was designed around a different relationship between programmer and machine, and its type system reflects that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Java: primitives, references, and the null question
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;ACTIVE&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;SUSPENDED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;DELETED&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Customer&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;discount&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;middleName&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;favoriteNumbers&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nc"&gt;Status&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Java distinguishes primitive types such as &lt;code&gt;int&lt;/code&gt;, &lt;code&gt;double&lt;/code&gt;, and &lt;code&gt;boolean&lt;/code&gt; from reference types such as &lt;code&gt;String&lt;/code&gt;, &lt;code&gt;Customer&lt;/code&gt;, and arrays.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;int&lt;/code&gt; cannot be &lt;code&gt;null&lt;/code&gt;. A &lt;code&gt;String&lt;/code&gt; can.&lt;/p&gt;

&lt;p&gt;That single asymmetry does a lot of work. It means absence is available for some of Alex's fields and structurally impossible for others, and the choice was made by whoever picked the field types rather than by anyone thinking about the domain. Unless we add conventions or tooling, &lt;code&gt;String middleName&lt;/code&gt; does not tell us whether null is an expected state or a bug waiting for the right execution path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What nothing looks like in Java&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spelling&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Who makes you deal with it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An empty reference&lt;/td&gt;
&lt;td&gt;Nobody&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;(not available)&lt;/td&gt;
&lt;td&gt;Primitives cannot be absent at all&lt;/td&gt;
&lt;td&gt;The compiler, by refusing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Java also has wrapper classes such as &lt;code&gt;Integer&lt;/code&gt;, which means two things that sound like "an integer" behave differently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="nc"&gt;Integer&lt;/span&gt; &lt;span class="n"&gt;optionalAge&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One of those can be absent. The other cannot. Learning the keyword is only the beginning; the useful question is what guarantees come with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rust: make some invalid states harder to represent
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Suspended&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Deleted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Customer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;discount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;middle_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;favorite_numbers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;i32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at &lt;code&gt;middle_name: Option&amp;lt;String&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Instead of allowing a &lt;code&gt;String&lt;/code&gt; to secretly contain null, Rust represents the possibility of absence in the type itself. The value is either &lt;code&gt;Some(...)&lt;/code&gt; or &lt;code&gt;None&lt;/code&gt;, and code consuming it has to deal with that possibility before it can get at the string.&lt;/p&gt;

&lt;p&gt;This is the same information every other language has been carrying around implicitly, finally written down somewhere the compiler can read it. The other languages let you forget. Rust makes forgetting a compile error.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Status&lt;/code&gt; enum similarly makes the valid alternatives explicit. We cannot accidentally assign &lt;code&gt;ACTVE&lt;/code&gt;, because no such variant exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What nothing looks like in Rust&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spelling&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Who makes you deal with it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;None&lt;/code&gt;, inside &lt;code&gt;Option&amp;lt;T&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;This value may legitimately be absent&lt;/td&gt;
&lt;td&gt;The compiler&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A type system can encode valid states, not merely storage categories. That does not make incorrect programs impossible. It moves some classes of error from runtime behaviour into representations the compiler can reject.&lt;/p&gt;

&lt;p&gt;The type system is participating in the design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go: useful zero values and deliberate simplicity
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Active&lt;/span&gt;    &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ACTIVE"&lt;/span&gt;
    &lt;span class="n"&gt;Suspended&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"SUSPENDED"&lt;/span&gt;
    &lt;span class="n"&gt;Deleted&lt;/span&gt;   &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"DELETED"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Customer&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt;            &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Age&lt;/span&gt;             &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;Discount&lt;/span&gt;        &lt;span class="kt"&gt;float64&lt;/span&gt;
    &lt;span class="n"&gt;MiddleName&lt;/span&gt;      &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;FavoriteNumbers&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;Status&lt;/span&gt;          &lt;span class="n"&gt;Status&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One important Go idea appears before we populate Alex at all: types have zero values. The zero value of an &lt;code&gt;int&lt;/code&gt; is &lt;code&gt;0&lt;/code&gt;, of a &lt;code&gt;bool&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;, of a &lt;code&gt;string&lt;/code&gt; is &lt;code&gt;""&lt;/code&gt;. Pointers, slices, maps, functions, channels, and interfaces can be &lt;code&gt;nil&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That creates a question. If &lt;code&gt;Age&lt;/code&gt; contains &lt;code&gt;0&lt;/code&gt;, is Alex zero years old, or did nobody supply an age?&lt;/p&gt;

&lt;p&gt;The type cannot answer. If the application uses a zero value to stand for “not supplied,” that state becomes indistinguishable from a legitimately supplied zero. If the distinction matters, the model has to represent it deliberately, which is why MiddleName above is a &lt;em&gt;string rather than a string.&lt;br&gt;
**What nothing looks like in Go&lt;/em&gt;*&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spelling&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Who makes you deal with it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;0&lt;/code&gt;, &lt;code&gt;""&lt;/code&gt;, &lt;code&gt;false&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Zero/default value; cannot by itself tell us whether a domain value was supplied&lt;/td&gt;
&lt;td&gt;The model must distinguish this if it matters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nil&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Empty pointer, slice, map, channel, or interface&lt;/td&gt;
&lt;td&gt;Nobody&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Convenient defaults acquire meaning only when they enter a domain.&lt;/p&gt;
&lt;h2&gt;
  
  
  R: "missing" is not one simple state
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight r"&gt;&lt;code&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Alex"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;42L&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;discount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0.15&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;middle_name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;NA_character_&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;favorite_numbers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;c&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;3L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;7L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;11L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ACTIVE"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;R was designed around statistical computing, and its types and missing-value semantics reflect that.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;NA&lt;/code&gt; represents a missing value, with typed forms such as &lt;code&gt;NA_integer_&lt;/code&gt; and &lt;code&gt;NA_character_&lt;/code&gt;. R also has &lt;code&gt;NULL&lt;/code&gt;, which is not another spelling of &lt;code&gt;NA&lt;/code&gt;, and numeric computation can produce &lt;code&gt;NaN&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;These answer different questions. A missing observation in a dataset is not the same thing as the absence of an object. Neither is the same thing as a mathematically undefined result. R treats those as three distinct facts, because in statistics they are three distinct facts, and conflating them produces wrong answers rather than crashes.&lt;/p&gt;

&lt;p&gt;Consider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight r"&gt;&lt;code&gt;&lt;span class="n"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;c&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;NA&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;c&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;NA&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;na.rm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;TRUE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What nothing looks like in R&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spelling&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Who makes you deal with it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NA&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A missing observation&lt;/td&gt;
&lt;td&gt;Functions ask, via &lt;code&gt;na.rm&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NULL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No object at all&lt;/td&gt;
&lt;td&gt;Nobody&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NaN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An undefined numeric result&lt;/td&gt;
&lt;td&gt;Nobody&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second call makes an explicit methodological choice to exclude missing observations. The language made you say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field that caused all the trouble
&lt;/h2&gt;

&lt;p&gt;Alex's missing middle name has now been through seven general-purpose languages. Stack those seven tables on top of each other and the third column tells the whole story.&lt;/p&gt;

&lt;p&gt;Python gave it &lt;code&gt;None&lt;/code&gt;, one undifferentiated nothing. JavaScript offered two and left the meaning to you. C gave a null pointer whose meaning lives entirely in a convention the compiler cannot see. Java made absence available for reference types and impossible for primitives, as a side effect of a decision about memory. Rust put it in the type signature and refused to let you ignore it. Go made it indistinguishable from a valid value unless you reach for a pointer. R split it into three concepts because its users needed three.&lt;/p&gt;

&lt;p&gt;Not one of those languages is wrong. They disagree about a prior question: how much does the programmer have to admit they do not know?&lt;/p&gt;

&lt;p&gt;Representations of absence carry semantics whether or not the programmer acknowledges them. The difference between these languages is who is required to do the acknowledging, and when.&lt;/p&gt;

&lt;p&gt;Which is why the missing middle name turned out to be more interesting than the integer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The table gets weird quickly
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Language&lt;/th&gt;
&lt;th&gt;Typing&lt;/th&gt;
&lt;th&gt;Ordinary integers&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;42&lt;/code&gt; and &lt;code&gt;0.15&lt;/code&gt; same type?&lt;/th&gt;
&lt;th&gt;How absence is spelled&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;Dynamic&lt;/td&gt;
&lt;td&gt;Arbitrary size&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;code&gt;None&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JavaScript&lt;/td&gt;
&lt;td&gt;Dynamic&lt;/td&gt;
&lt;td&gt;Fixed (&lt;code&gt;BigInt&lt;/code&gt; for more)&lt;/td&gt;
&lt;td&gt;Yes, both &lt;code&gt;number&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;null&lt;/code&gt; and &lt;code&gt;undefined&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;Static&lt;/td&gt;
&lt;td&gt;Fixed width, chosen&lt;/td&gt;
&lt;td&gt;Usually no&lt;/td&gt;
&lt;td&gt;Null pointer, by convention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Java&lt;/td&gt;
&lt;td&gt;Static&lt;/td&gt;
&lt;td&gt;Fixed width&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;null&lt;/code&gt;, references only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;Static&lt;/td&gt;
&lt;td&gt;Fixed width&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Option&amp;lt;T&amp;gt;&lt;/code&gt;, in the type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;Static&lt;/td&gt;
&lt;td&gt;Fixed width&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Zero values, &lt;code&gt;nil&lt;/code&gt; for some types&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R&lt;/td&gt;
&lt;td&gt;Dynamic&lt;/td&gt;
&lt;td&gt;Fixed width&lt;/td&gt;
&lt;td&gt;Usually distinct&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;NA&lt;/code&gt;, &lt;code&gt;NULL&lt;/code&gt;, &lt;code&gt;NaN&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Closed sets of alternatives vary just as much. Rust, Java, and C have real enums, Python offers &lt;code&gt;Enum&lt;/code&gt; if you reach for it, Go builds them from a named type plus constants, JavaScript models them by hand, and R uses factors. Collections differ too: C, Java, Rust, and Go enforce an element type in the declaration, Python and JavaScript do not, and R's atomic vectors quietly coerce everything toward a common type.&lt;/p&gt;

&lt;p&gt;There is no universal translation saying:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Python int = JavaScript number = C int = Java int = Rust i32 = Go int = R integer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those types overlap. They do not carry identical guarantees, ranges, operations, failure modes, or assumptions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The same name does not imply the same contract.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  If you were designing a language, what would you choose?
&lt;/h2&gt;

&lt;p&gt;This gets much more interesting when you reverse the problem.&lt;/p&gt;

&lt;p&gt;Suppose you are designing a language.&lt;/p&gt;

&lt;p&gt;You need an integer.&lt;/p&gt;

&lt;p&gt;Easy.&lt;/p&gt;

&lt;p&gt;Except now you have questions. Fixed widths, or one integer that grows until memory runs out? What happens on overflow? Should &lt;code&gt;3&lt;/code&gt; become &lt;code&gt;3.0&lt;/code&gt; on its own? Should &lt;code&gt;"42" + 1&lt;/code&gt; be legal, and if so, what should it mean?&lt;/p&gt;

&lt;p&gt;Then absence, which by now you can see is the hard one. Do you have &lt;code&gt;null&lt;/code&gt;? Can every reference be null, or do you require an explicit optional type? Do you distinguish a missing observation from a non-existent object? Whichever you choose, every program ever written in your language inherits that decision.&lt;/p&gt;

&lt;p&gt;Then collections, where you decide whether elements must share a type and whether values can be moved or consumed. Then domain state, where you decide whether a programmer can say a value is only ever &lt;code&gt;ACTIVE&lt;/code&gt;, &lt;code&gt;SUSPENDED&lt;/code&gt;, or &lt;code&gt;DELETED&lt;/code&gt;, and whether the compiler can prove every case was handled.&lt;/p&gt;

&lt;p&gt;Finally, timing. When should a bad type relationship surface: while parsing, during compilation, at runtime, or only when the offending path finally executes?&lt;/p&gt;

&lt;p&gt;There is no universally correct set of answers. There are consequences.&lt;/p&gt;

&lt;p&gt;Every choice makes some programs easier to express, some mistakes harder to make, some implementations simpler, some runtimes more complicated, and some programmers wonder why on Earth the language designer did that.&lt;/p&gt;

&lt;p&gt;Sometimes the answer is historical compatibility. Sometimes performance. Sometimes safety. Sometimes the problem domain.&lt;/p&gt;

&lt;p&gt;And sometimes the language designer has a very good explanation and would appreciate it if everyone stopped bringing up &lt;code&gt;typeof null&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  One language where absence is not the question
&lt;/h2&gt;

&lt;p&gt;Everything above is a general-purpose language, and every one of them had to decide what nothing means. That is not a universal preoccupation. It is what happens when a language expects you to model a world.&lt;/p&gt;

&lt;p&gt;The e language, commonly associated with Cadence Specman, was designed for hardware verification. Verification code describes legal ranges of values, generates constrained test data, and explores systems whose state spaces are far too large to enumerate by hand. So its declarations carry constraints rather than storage categories:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;struct customer {
    age : uint (bits: 8);
    keep age in [18..120];

    status : [ACTIVE, SUSPENDED, DELETED];
    keep soft status == ACTIVE;
};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no absence card for e in this article, and that is the point. In ordinary application programming we ask what type of value this is. A verification environment asks what values are legal here, and how valid combinations should be generated so we can explore what the system does.&lt;/p&gt;

&lt;p&gt;If you look only at Python, JavaScript, Java, and C, it is easy to assume programming languages are all solving approximately the same problem with different punctuation.&lt;/p&gt;

&lt;p&gt;They aren't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Types are compressed language philosophy
&lt;/h2&gt;

&lt;p&gt;At the beginner level, "a type tells us what kind of data a value contains" is perfectly serviceable. Eventually that runs out of road. A type can tell us what values exist, how they are stored, which operations are permitted, which conversions happen silently, what counts as absence, whether absence must be acknowledged, whether alternatives form a closed set, and in some languages how long a value lives and who owns it. "Integer, string, Boolean, float" is not the definition of a type system. It is the introductory vocabulary.&lt;/p&gt;

&lt;p&gt;Which is why learning the types in a new language beats memorizing a conversion table.&lt;/p&gt;

&lt;p&gt;Python's arbitrary-precision integers are a tradeoff. So is C asking you to care about width. Java's primitive and reference split quietly decides which of your fields are allowed to be empty. Go buys simplicity with zero values and pays for it with ambiguity at the domain boundary. R distinguishes forms of missingness because missing observations are a normal feature of statistical work, so its problem domain is visible in its types. A verification language making constrained generation first-class tells you what its designers expected programmers to spend their days doing.&lt;/p&gt;

&lt;p&gt;A language's types are partly a record of what its designers thought mattered, and partly a record of what they were willing to let you avoid thinking about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;They are compressed language philosophy.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Alex survived
&lt;/h2&gt;

&lt;p&gt;After all of this, Alex is still 42, still has no recorded middle name, still likes 3, 7, and 11, and remains active. Nothing about the underlying person changed. But every language forced different decisions about how those facts became computation, and the fact that changed most was the one that was not there.&lt;/p&gt;

&lt;p&gt;Knowing that Python has &lt;code&gt;int&lt;/code&gt;, Java has &lt;code&gt;int&lt;/code&gt;, and Rust has &lt;code&gt;i32&lt;/code&gt; is useful when you are trying to get a program running. Understanding why they are not interchangeable is useful when you are trying to understand languages.&lt;/p&gt;

&lt;p&gt;Once you start asking what a language lets you represent, what it makes you acknowledge, and what it refuses to let you say, data types stop being the boring chapter before loops. They become one of the quickest ways to see what a language believes programming should be.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What is the strangest, most useful, or most frustrating type decision you've encountered in a programming language? More importantly, what problem do you think the language designer was trying to solve with it? Or, if you were designing your own language, which of these decisions would you make differently?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>javascript</category>
      <category>rust</category>
      <category>computerscience</category>
    </item>
    <item>
      <title>Enterprise MCP Gateway to Govern MCP Tool Access</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Tue, 06 Oct 2026 04:57:00 +0000</pubDate>
      <link>https://dev.to/kenwalger/enterprise-mcp-gateway-to-govern-mcp-tool-access-mam</link>
      <guid>https://dev.to/kenwalger/enterprise-mcp-gateway-to-govern-mcp-tool-access-mam</guid>
      <description>&lt;h2&gt;
  
  
  Model access controls what an AI system can know and generate. Tool access controls what it can do.
&lt;/h2&gt;

&lt;p&gt;The previous posts in The AI Control Plane have moved progressively closer to the point where policy becomes action. &lt;a href="https://dev.to/kenwalger/ai-governance-for-enterprise-ai-why-governance-comes-before-the-gateway-2hm1"&gt;Governance&lt;/a&gt; established who gets to decide. &lt;a href="https://dev.to/kenwalger/ai-access-control-for-enterprise-ai-turning-policy-into-runtime-enforcement-5bkk"&gt;Virtual keys&lt;/a&gt; turned those decisions into runtime policy. &lt;a href="https://dev.to/kenwalger/rbac-for-ai-governing-the-ai-control-plane-e59"&gt;RBAC and access profiles&lt;/a&gt; constrained who could change that policy. &lt;a href="https://dev.to/kenwalger/why-user-provisioning-matters-for-enterprise-ai-534m"&gt;User provisioning&lt;/a&gt; made those permissions follow people as they joined, moved, and left.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.getmaxim.ai/bifrost" rel="noopener noreferrer"&gt;Bifrost&lt;/a&gt; is the open-source AI gateway through which this series has been examining those control-plane concerns, with its implementation available in the &lt;a href="https://getmax.im/githubdevto" rel="noopener noreferrer"&gt;Bifrost GitHub repository&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;MCP tool access changes the stakes. The &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/getting-started/intro" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; defines tools as capabilities exposed by servers that clients can discover and invoke. A model allowed to answer a question can produce a bad answer. A model allowed to invoke a tool can change another system. It can create a ticket, query a customer database, update a record, trigger a deployment, send a message, or call whatever capability an MCP server exposes.&lt;/p&gt;

&lt;p&gt;The question is no longer only which model may receive a request. It is which actions that model can reach, under whose authority, and what happens when a request crosses from inference into execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Is MCP Tool Access Different From Model Access?
&lt;/h2&gt;

&lt;p&gt;Model governance controls destinations and consumption. Which providers are approved, which models a workload may use, how much it may spend, and what rate limits apply. Tool governance adds capability.&lt;/p&gt;

&lt;p&gt;Consider a customer support agent with access to three tools: &lt;code&gt;lookup_customer&lt;/code&gt;, &lt;code&gt;issue_refund&lt;/code&gt;, and &lt;code&gt;close_account&lt;/code&gt;. All three may live on the same MCP server, behind the same authentication, exposed through the same connection. They do not have the same consequences.&lt;/p&gt;

&lt;p&gt;That is why the useful unit of governance is not the server. A server is a deployment boundary. A tool is an action boundary, and the two rarely line up.&lt;/p&gt;

&lt;p&gt;Authentication does not close the gap either. Valid credentials for an MCP server prove a connection can be made; they say nothing about whether a given workload should reach every capability behind it. This is the same separation the virtual key post drew between holding a provider credential and being permitted to use a particular model, and it gets sharper when MCP authentication is per-user. A person may legitimately authenticate to an external service while the AI workload acting on their behalf should still receive a subset of what that service offers. The credential establishes identity at the external boundary. The control plane still has to govern capability at the tool boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Should MCP Authorization Policy Control?
&lt;/h2&gt;

&lt;p&gt;The simplest policy is an allow list, and it does more than it appears to, because &lt;a href="https://docs.getbifrost.ai/mcp/filtering" rel="noopener noreferrer"&gt;filtering&lt;/a&gt; constrains discovery as well as execution. A workload never needs to be shown capabilities it will not be permitted to invoke. Bifrost applies that filtering per request, per client, or per virtual key, which means tool access can attach to the same policy boundary that already governs provider access, model allow lists, budgets, and expiry.&lt;/p&gt;

&lt;p&gt;Enterprise authorization needs more structure than a growing collection of individual tool grants. &lt;strong&gt;Access profiles&lt;/strong&gt; provide a reusable policy layer for grouping permissions around a role or workload rather than configuring each identity independently. A support workload, for example, can inherit the provider, model, and MCP permissions appropriate to support without requiring those decisions to be recreated every time another user or key is provisioned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Virtual MCP servers&lt;/strong&gt; provide another boundary by controlling the capability surface presented to a workload. Rather than treating every tool exposed by every connected MCP server as one flat namespace, an organization can present a curated MCP surface appropriate to a particular application or class of users. Tool filtering can then narrow that surface further to the specific actions a workload is authorized to discover and invoke.&lt;/p&gt;

&lt;p&gt;Together, those layers answer different authorization questions. An access profile establishes the policy associated with an identity or workload. A virtual MCP server establishes the MCP capability surface available to it. Tool-level policy determines which actions within that surface are actually reachable.&lt;/p&gt;

&lt;p&gt;That distinction matters because enterprise policy gets nuanced quickly. A research agent may search documentation and retrieve customer information without modifying either. A support agent may open a case while refunds route through a separate path. An engineering agent may inspect deployment state while production mutation stays out of reach. Policy should track the consequence of the action rather than the convenience of the integration.&lt;/p&gt;

&lt;p&gt;Because those permissions live in the control plane rather than in twenty repositories, changing the decision does not require twenty releases. It also means refusal happens in the right place. If a workload is not permitted to invoke &lt;code&gt;issue_refund&lt;/code&gt;, the useful outcome is not that the refund service rejects the call later. It is that the refund service never sees it. The application receives a deterministic policy refusal instead of an unpredictable downstream error, and the external system is never asked to adjudicate an unauthorized attempt.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changes When an Agent Can Execute Tools Automatically?
&lt;/h2&gt;

&lt;p&gt;There is a meaningful difference between a model suggesting a tool call and infrastructure executing one, and Bifrost's default preserves it. Tool calls returned by a model are suggestions, not actions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. POST /v1/chat/completions  
   → model returns tool call suggestions, not executed

2. Application reviews the proposed calls  
   → apply business rules, request human approval

3. POST /v1/mcp/tool/execute  
   → execute approved calls explicitly

4. POST /v1/chat/completions  
   → continue the conversation with tool results  
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step two is the pause. It is where an application can inspect a proposal, ask a human, apply additional logic, or refuse outright.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.getbifrost.ai/mcp/agent-mode" rel="noopener noreferrer"&gt;Agent mode&lt;/a&gt; removes it. Tools named in the &lt;code&gt;tools_to_auto_execute&lt;/code&gt; configuration run without waiting for approval, which is useful precisely because it eliminates friction and consequential for the same reason.&lt;/p&gt;

&lt;p&gt;So the authorization decision moves earlier. If no human will be present when the action is taken, the control plane has to establish beforehand which actions are acceptable. An allow list stops being a convenience for hiding irrelevant tools and becomes part of the safety boundary around autonomous execution. That does not mean every call needs an approval dialog. It means the absence of a human at execution time makes pre-established policy more important, not less.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Should Enterprises Think About MCP Tool Blast Radius?
&lt;/h2&gt;

&lt;p&gt;A workable way to classify tools is by what happens when the authorization turns out to be wrong.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Primary Consequence&lt;/th&gt;
&lt;th&gt;Governance Posture&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Read&lt;/td&gt;
&lt;td&gt;Search documentation&lt;/td&gt;
&lt;td&gt;Information exposure&lt;/td&gt;
&lt;td&gt;Allow by role and data scope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Create&lt;/td&gt;
&lt;td&gt;Open support ticket&lt;/td&gt;
&lt;td&gt;New system state&lt;/td&gt;
&lt;td&gt;Restrict destination and ownership&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Modify&lt;/td&gt;
&lt;td&gt;Update customer record&lt;/td&gt;
&lt;td&gt;Existing state changes&lt;/td&gt;
&lt;td&gt;Narrow scope and retain evidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Financial&lt;/td&gt;
&lt;td&gt;Issue refund&lt;/td&gt;
&lt;td&gt;Monetary loss&lt;/td&gt;
&lt;td&gt;Explicit grant and tighter limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Destructive&lt;/td&gt;
&lt;td&gt;Delete account&lt;/td&gt;
&lt;td&gt;Irreversible or costly&lt;/td&gt;
&lt;td&gt;Exceptional access or added approval&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The categories matter less than the exercise. Two tools on the same server can sit three rows apart, which is the concrete reason server-level grants are too coarse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Do Individually Safe Tools Become Unsafe Together?
&lt;/h2&gt;

&lt;p&gt;Here is the part that no allow list solves.&lt;/p&gt;

&lt;p&gt;A workload permitted to read customer data and to send email has a different effective capability from one permitted to do either alone. An agent holding a database tool, a payment tool, and outbound messaging is not carrying three permissions. It is carrying a workflow, and nobody approved the workflow. Each grant was reviewed on its own and each was defensible on its own.&lt;/p&gt;

&lt;p&gt;This is an uncomfortable property of agentic systems: capability emerges from combinations of individually reasonable permissions, and the emergent capability is a property of the set rather than of any member of it. Filtering evaluates tools one at a time, because that is what filtering is. Reviewing grants one at a time will not surface it either, for the same reason.&lt;/p&gt;

&lt;p&gt;There is no product answer as no gateway-level enforcement exists today. What there is, for now, is a review practice: when adding a tool to an existing grant, ask what the new tool makes possible in combination with what is already there, rather than whether the new tool is acceptable by itself. That is a weaker control than enforcement, and it is worth naming as a gap rather than papering over it.&lt;/p&gt;

&lt;p&gt;A second assumption runs underneath everything above: that the MCP server itself is trustworthy, and the only open question is which of its tools a given workload should reach. That assumption deserves its own scrutiny, because a compromised or careless server can return content engineered to be read as instruction rather than as data. I have written separately about &lt;a href="https://www.kenwalger.com/blog/ai/mcp/mcp-containment-boundaries-ai-agents/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=maxim_ai_bifrost" rel="noopener noreferrer"&gt;classifying MCP servers by trust zone&lt;/a&gt;, which is the layer beneath the one this post governs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who Authorized This Tool, and What Happens When They Leave?
&lt;/h2&gt;

&lt;p&gt;Filtering answers whether a call is permitted right now. It does not answer why.&lt;/p&gt;

&lt;p&gt;"The developer added the tool to the agent" describes implementation, not authority. The permission may have arrived through a team role, an access profile default, a temporary exception, or a direct assignment created during an incident at two in the morning. Those paths have different lifecycle behavior, which is where the &lt;a href="https://dev.to/kenwalger/why-user-provisioning-matters-for-enterprise-ai-534m"&gt;provisioning problem&lt;/a&gt; returns. A tool grant attached to an identity has to disappear when the authority behind it disappears. Deleting a user or key causes Bifrost to clean up the associated per-user credentials. Session-keyed credentials and upstream provider tokens, however, may have separate revocation lifecycles that administrators need to account for.  &lt;/p&gt;

&lt;p&gt;And a tool grant attached to a workload with no human identity needs an owner and a lifecycle of its own, because nothing will trigger one on its behalf.&lt;/p&gt;

&lt;p&gt;MCP governance is not a list of allowed tool names. It is the connection between capability and authority.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why MCP Governance Belongs in the AI Control Plane
&lt;/h2&gt;

&lt;p&gt;This series has worked through increasingly specific forms of control: organizational governance, workload policy, administrative authority, identity lifecycle, and now capability. Tools are where those controls finally meet something able to change the world outside the AI stack.&lt;/p&gt;

&lt;p&gt;Which is why tool governance cannot stop at authentication, server access, or whether a model supports function calling. Enterprises have to decide which capabilities are visible, which are executable, which demand stronger authority, and how all of that changes when the people and workloads behind them change. Bifrost brings those decisions into the same control plane as model access, routing, and budgets, and because &lt;a href="https://getmax.im/githubdevto" rel="noopener noreferrer"&gt;it is open source&lt;/a&gt;, the &lt;a href="https://docs.getbifrost.ai/mcp/overview" rel="noopener noreferrer"&gt;MCP implementation&lt;/a&gt; can be inspected rather than taken on trust.&lt;/p&gt;

&lt;p&gt;The principle outlives any implementation. Model access governs what AI may ask. Tool access governs what AI may do.&lt;/p&gt;

&lt;p&gt;Five layers in, the remaining question is not which controls exist. It is what order to build them in, and what breaks when you get the order wrong.&lt;/p&gt;

&lt;p&gt;Bifrost brings these controls into the AI control plane, giving teams a central place to manage MCP authorization alongside the rest of their AI infrastructure. To see how that works in practice, &lt;a href="https://getmaxim.ai/bifrost/book-a-demo" rel="noopener noreferrer"&gt;book a Bifrost demo&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt; This article was commissioned by the &lt;a href="https://www.getmaxim.ai/bifrost" rel="sponsored nofollow noopener noreferrer"&gt;Bifrost team&lt;/a&gt;. Links to kenwalger.com and to the &lt;a href="https://sovereignplatform.dev?utm_source=devto&amp;amp;utm_medium=footer&amp;amp;utm_campaign=ai-control-plane" rel="noopener noreferrer"&gt;Sovereign Systems Specification&lt;/a&gt; point to my own work. The architectural perspective and conclusions expressed here are my own.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>security</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Views Measure Views</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Thu, 01 Oct 2026 19:32:00 +0000</pubDate>
      <link>https://dev.to/kenwalger/views-measure-views-4co7</link>
      <guid>https://dev.to/kenwalger/views-measure-views-4co7</guid>
      <description>&lt;p&gt;&lt;em&gt;Nine years in, I finally worked out what else to count.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A writer I follow, &lt;a href="https://dev.to/sylwia-lask"&gt;Sylwia Laskowska&lt;/a&gt;, recently published a post about &lt;a href="https://dev.to/sylwia-lask/the-accidental-blogger-how-i-ended-up-on-dev-5a3f"&gt;accidentally becoming a blogger&lt;/a&gt;. She has been writing on DEV for about a year, and the numbers attached to that year are impressive: hundreds of thousands of views and tens of thousands of followers.&lt;/p&gt;

&lt;p&gt;I have been on DEV for more than nine years. This morning I am at 48,408 total views.&lt;/p&gt;

&lt;p&gt;Before I go any further, I want to be clear about something, because the essay that usually follows a comparison like that is insufferable. Building a large readership for accessible, useful developer writing is genuinely difficult, and doing it in a year is more difficult still. I would like more people to read my work. I can learn a great deal from writers who are better than I am at audience building, topic selection, accessibility, and community participation.&lt;/p&gt;

&lt;p&gt;So this is not a piece about why small numbers are secretly good. It is a piece about what I spent nine years failing to separate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that stopped me using one of my metrics
&lt;/h2&gt;

&lt;p&gt;I recently pulled nine years of my own data out of the DEV API, mostly to make a chart of follower growth with my publication dates marked on it, so I could see which posts moved the line.&lt;/p&gt;

&lt;p&gt;The chart was useless, and the reason is instructive.&lt;/p&gt;

&lt;p&gt;In 2026 I gained roughly eighteen thousand followers. My 2026 posts have about fourteen thousand views between them. You cannot acquire eighteen thousand readers from fourteen thousand page loads. The daily follow rate sits around 130 and does not respond to whether I publish anything, and about 37% of the usernames carry auto-generated hex or numeric tails. It is reciprocal-follow farming, it is endemic, and it has nothing to do with me or my writing.&lt;/p&gt;

&lt;p&gt;Here is the cleanest version of it. Since the middle of September, my follower count has gone from 18,148 to 21,048. Over the same fifteen days, my total view count went from 46,774 to 48,408.&lt;/p&gt;

&lt;p&gt;Two thousand nine hundred new followers. One thousand six hundred and thirty-four new views.&lt;/p&gt;

&lt;p&gt;I gained nearly twice as many followers as readers, and a follower is supposed to be a reader who liked something enough to want more. That number had been sitting on my profile for months looking like evidence of something.&lt;/p&gt;

&lt;p&gt;A page view is at least an honest measurement. Somebody loaded the page. That is real information, and I think "vanity metric" is an unfair label if it is taken to mean meaningless.&lt;/p&gt;

&lt;p&gt;The trouble starts when we quietly change the claim from "this post received more views" to "this post was more successful."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Successful at what?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A page view is real. It just is not the whole story.
&lt;/h2&gt;

&lt;p&gt;In corporate content the answer to that question is usually explicit. A post might exist to attract someone searching for a problem, introduce a product, move that reader toward a trial, and eventually help create a customer. Ten thousand views with no downstream behaviour may be worth less to that company than five hundred views that put twenty qualified developers into the funnel.&lt;/p&gt;

&lt;p&gt;Personal writing has a funnel too, just a much vaguer one. Someone reads one article, encounters another a month later, starts recognising the name, follows, leaves a substantive comment, references the work elsewhere. The page view was real. It was not necessarily the outcome.&lt;/p&gt;

&lt;p&gt;It also helps to remember that a broadly useful JavaScript tutorial and an article about authority boundaries in AI-generated software are not competing for the same reader. One is relevant to a large share of a developer community. The other starts with a much smaller pool of people who already care about capability security and the distinction between correctness and authority.&lt;/p&gt;

&lt;p&gt;That is not so different from comparing the audience for a popular fantasy novel with the audience for a presidential memoir. Both books can be excellent. Both can do exactly what their authors intended. Their potential readerships are still radically different.&lt;/p&gt;

&lt;p&gt;Audience size is partly a property of the artifact and partly a property of the market around it. That sounds obvious about books. Writers forget it remarkably fast while staring at a dashboard.&lt;/p&gt;

&lt;p&gt;An article can also have a desired consequence without a button under it. Sometimes I want someone to challenge the argument. Sometimes I want a developer to recognise a problem in their own system. Sometimes the entire intended outcome is that a reader leaves with a question they were not asking ten minutes earlier. That still gives me something to evaluate against. It just refuses to appear as &lt;code&gt;conversion=true&lt;/code&gt; in an analytics dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quality, audience fit, and distribution are different problems
&lt;/h2&gt;

&lt;p&gt;I have come to think of online writing as at least three separate problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Writing quality&lt;/strong&gt; is the craft problem. Is the argument coherent? Is the explanation useful? Did I do the research? Is there something here worth another person's time?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audience fit&lt;/strong&gt; is the relevance problem. How many people where I publish are likely to care about this subject? How much prerequisite knowledge does it demand? Can someone scrolling a feed see immediately why the question matters to them?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distribution&lt;/strong&gt; is the discovery problem. How does the article reach those people? Search, followers, newsletters, speaking, community participation, platform curation, links from other writers, or some combination.&lt;/p&gt;

&lt;p&gt;Those interact, but they are not interchangeable. An article has to clear all three, and failing any one of them produces the same disappointing number for completely different reasons.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    W["Article"] --&amp;gt; Q{"Quality"}
    Q --&amp;gt;|"weak"| X1["Nobody finishes it"]
    Q --&amp;gt;|"strong"| F{"Audience fit"}
    F --&amp;gt;|"wrong room"| X2["Few people care"]
    F --&amp;gt;|"right room"| D{"Distribution"}
    D --&amp;gt;|"not found"| X3["Nobody sees it"]
    D --&amp;gt;|"found"| R["Readers"]

    classDef gate fill:#FFFFFF,stroke:#166534,color:#14532D,stroke-width:2px;
    classDef miss fill:#FEF2F2,stroke:#991B1B,color:#7F1D1D,stroke-width:2px;
    classDef good fill:#E8F3EE,stroke:#166534,color:#14532D,stroke-width:2px;

    class Q,F,D gate;
    class X1,X2,X3 miss;
    class W,R good;&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;That is the diagnostic value of separating them. Three posts can land at 80 views apiece and need three entirely different responses. A good article can have poor audience fit. An accessible article can have excellent fit and no distribution. A technically modest piece can answer a question a hundred thousand people are asking today. A strong argument can address a question five hundred people know they have.&lt;/p&gt;

&lt;p&gt;For most of my writing life I concentrated almost entirely on the first variable and assumed distribution would sort itself out. Sometimes it did. Frequently it did not. My own numbers say that plainly: about 4% of my traffic comes from search, which for someone whose best-performing historical work is evergreen reference material is a distribution problem rather than a quality one.&lt;/p&gt;

&lt;h2&gt;
  
  
  I did not start with a content strategy
&lt;/h2&gt;

&lt;p&gt;Nine years ago my public technical writing grew out of databases, because databases were the work I was doing and the community I was in. I did not sit down with a personal-brand document. I wrote about what I knew, what I was learning, and what developers were asking about.&lt;/p&gt;

&lt;p&gt;Those articles accumulated into something larger. People began associating my name with certain subjects, questions led to more articles, and some of that work became durable enough to keep attracting readers years later. My database writing eventually included MongoDB's &lt;a href="https://www.mongodb.com/company/blog/building-with-patterns-a-summary" rel="noopener noreferrer"&gt;Building with Patterns&lt;/a&gt;, one of the most heavily visited bodies of content I worked on there.&lt;/p&gt;

&lt;p&gt;That history matters when I wander. If I publish one &lt;a href="https://rust-lang.org/" rel="noopener noreferrer"&gt;Rust&lt;/a&gt; article today, it does not arrive with nine years of association between my name and Rust behind it. That does not mean developers are uninterested in Rust, or that my readers dislike it. It may simply mean I have not given a Rust audience any reason to know who I am.&lt;/p&gt;

&lt;p&gt;Those explanations imply completely different responses. If I wanted Rust to become a real part of my writing, one underperforming article would tell me almost nothing. I would need several useful pieces, participation in that community, and time. If I do not want that, the article stays an interesting experiment.&lt;/p&gt;

&lt;p&gt;"This article performed poorly" is an observation. "There is no audience for me here" is an interpretation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Personal writing gets to discover its strategy
&lt;/h2&gt;

&lt;p&gt;I have spent enough of my career around corporate developer content to know that content strategy matters. A company generally knows why it is publishing: which developers it wants to reach, which capabilities it needs explained, which search terms it wants to own. The strategy should exist before anyone fills the editorial calendar.&lt;/p&gt;

&lt;p&gt;Personal writing is stranger. You can chase a question because it bothered you on Tuesday. You can abandon a series when you have nothing else useful to say. You can spend weeks on a technical experiment and then publish something ridiculous because you started wondering whether all the photographs on your phone technically make it heavier.&lt;/p&gt;

&lt;p&gt;You can also discover the strategy after you have written enough to see the pattern. That has increasingly been my experience. Articles I thought were about AI verification, provenance, missing information, audit evidence, memory, and authority turned out to be different views of the same few questions. I did not design that body of work and then manufacture articles to fill it. The writing is how I found it.&lt;/p&gt;

&lt;p&gt;The conversation that prompted this piece made me realise that is less different from corporate strategy than I assumed. Sylwia described writing mostly by intuition while still making choices about what she wants to be known for, which audiences interest her, which adjacent topics fit, and which opportunities she ignores.&lt;/p&gt;

&lt;p&gt;That is a content strategy. It just has a governance structure of one.&lt;/p&gt;

&lt;p&gt;A company may need content, DevRel, product marketing, SEO, and leadership involved in deciding whether a newly discovered audience matters. A personal writer can notice something in the comments on Tuesday and run the experiment on Thursday. The strategic question is nearly identical. The path from observation to decision is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signals are not instructions
&lt;/h2&gt;

&lt;p&gt;This matters because audiences talk back. A recurring question in the comments may reveal an adjacent audience you did not know you had. Search traffic may show people finding an article for a reason you never anticipated. A series may attract platform engineers when you thought you were writing for application developers.&lt;/p&gt;

&lt;p&gt;That is useful information. It is not an order.&lt;/p&gt;

&lt;p&gt;A writer can discover that beginner tutorials have an enormous reachable audience and still decide not to build a body of work around them. A company can discover that a group of users loves a product for an unexpected use case and still decide that market does not fit the strategy.&lt;/p&gt;

&lt;p&gt;Discovering an audience is not the same as deciding to serve it.&lt;/p&gt;

&lt;p&gt;This is where "write more of whatever did best last week" collapses. It is the same loop with one step deleted.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    A["Write"] --&amp;gt; B["Publish"]
    B --&amp;gt; C["Signals&amp;lt;br/&amp;gt;views · comments&amp;lt;br/&amp;gt;search terms · who shows up"]
    C --&amp;gt; D{"Is this where&amp;lt;br/&amp;gt;I want to go?"}
    D --&amp;gt;|"yes"| E["Build an audience here"]
    D --&amp;gt;|"no"| F["Note it. Leave it alone."]
    E --&amp;gt; G["Strategy"]
    F --&amp;gt; G
    G --&amp;gt; A

    classDef step fill:#E8F3EE,stroke:#166534,color:#14532D,stroke-width:2px;
    classDef signal fill:#FFFFFF,stroke:#5CA08A,color:#14532D,stroke-width:2px;
    classDef gate fill:#FEF2F2,stroke:#991B1B,color:#7F1D1D,stroke-width:2px;

    class A,B,E,F,G step;
    class C signal;
    class D gate;&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The diamond is the part that gets skipped. Without it the loop still runs, it just runs on autopilot, and the writer ends up somewhere chosen by whatever the feed rewarded in a given week.&lt;/p&gt;

&lt;p&gt;Metrics tell you what happened. Readers reveal opportunities you did not know to look for. Neither one gets to decide what you want the work to become. The feedback loop needs interpretation.&lt;/p&gt;

&lt;h2&gt;
  
  
  I eventually wrote down some rules
&lt;/h2&gt;

&lt;p&gt;Once I could see a body of work forming, it became tempting to turn every passing thought into another strategic article. So I wrote myself a gate.&lt;/p&gt;

&lt;p&gt;For the deliberate part of my technical writing, I now ask whether there is a disputable claim, whether investigating it will put pressure on an actual artifact, whether I have standing through a project or experiment, whether it advances rather than repeats the larger body of work, and whether a reader should do or question something differently afterward. I also want to know what would falsify the claim before I start assembling evidence for it.&lt;/p&gt;

&lt;p&gt;The artifact-pressure test is deliberately hard. If investigating an idea will not change code, a specification, an ADR, a schema, or a demo, it probably does not belong in that stream. The investigation also has to be capable of failing. Building fixtures that encode what I already believe proves very little.&lt;/p&gt;

&lt;p&gt;That produces a shape I have become fond of:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Here is what I thought.&lt;br&gt;
Here is what would have convinced me I was wrong.&lt;br&gt;
Here is what I built.&lt;br&gt;
Here is what happened.&lt;br&gt;
Here is what changed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Those are not my rules for everything. I deliberately keep another lane with almost no gate at all: career observations, language experiments, satire, community responses, project archaeology, and pure curiosity only need to be worth writing.&lt;/p&gt;

&lt;p&gt;A publishing strategy should help me recognise strong work. It should not make me ask permission before being curious.&lt;/p&gt;

&lt;h2&gt;
  
  
  Some of it is luck
&lt;/h2&gt;

&lt;p&gt;There is one variable I cannot put into a strategy with any confidence.&lt;/p&gt;

&lt;p&gt;I can study years of data and conclude that Tuesday at 9:00 a.m. Pacific is the right time to publish. That says nothing about whether it is right for &lt;em&gt;this&lt;/em&gt; article. A major news event may take the morning. Three other posts aimed at the same readers may appear within the hour. A moderator may promote something. A Gem may land. Another writer with a large audience may link to you. None of that says anything new about the quality of the work, and each can transform its distribution.&lt;/p&gt;

&lt;p&gt;Strategy does not eliminate luck. It changes the conditions under which luck operates. You can improve the writing, understand the audience, participate in the community, publish consistently enough that people know you exist, and make the work easy to find.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You can engineer more opportunities for an outcome without engineering the outcome itself.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And when luck does hand you an unexpected success, strategy comes back. A surprising audience appearing is another signal, not a mandate. You still have to decide whether it points somewhere you want to go.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually track now
&lt;/h2&gt;

&lt;p&gt;I still look at views. Pretending otherwise would be silly. If one article gets 5,000 and another gets 40, I want to know why. I just no longer think the first was 125 times more successful.&lt;/p&gt;

&lt;p&gt;The outcomes I care most about do not fit in a platform dashboard. A reader challenges a claim and I change the model. A comment exposes a missing invariant. An experiment breaks the answer I expected to publish. An article changes code, a specification, or an architecture decision.&lt;/p&gt;

&lt;p&gt;That has become much less theoretical lately.&lt;/p&gt;

&lt;p&gt;I published an article that began with a refractometer and a batch of homemade wine, arguing about the difference between a measurement and the state we infer from it. The comments pushed it considerably further: version the correction rule, spend more measurement budget on consequential baselines, decide how conflicting instruments get adjudicated before seeing the readings, distinguish evidence that survives a restart from evidence that dies with the process.&lt;/p&gt;

&lt;p&gt;An article about authority boundaries in AI-generated code did the same thing. Readers pushed on capability lifetime, consumable authority, semantic authority diffs, denied-call telemetry, and who is permitted to modify the authority boundary itself.&lt;/p&gt;

&lt;p&gt;Those comments did not just increase engagement. They changed the model. Those are propagation effects rather than distribution metrics, and I have started tracking them separately: research-induced change, engagement from people with standing outside my field, independent use of a concept, substantive challenges and extensions, and whether writing has started consuming so much time that the projects supplying it have stopped moving.&lt;/p&gt;

&lt;p&gt;My numbers support the split more cleanly than I expected. Across nine years I have 1,372 reactions and 683 comments. Roughly one comment for every two reactions is a strange ratio, and it is concentrated almost entirely in recent work. My 2017 tutorials pulled more than twice the traffic of everything I wrote in 2026 and produced 26 comments in three years. The 2026 essays, with a third of the traffic, have produced hundreds. One of them has a comment thread 35 replies deep.&lt;/p&gt;

&lt;p&gt;One body of work got found and skimmed. The other gets read and argued with. For years I evaluated both with the same number.&lt;/p&gt;

&lt;p&gt;A related consequence: a technically unsuccessful investigation can make a successful article. If I start with a hypothesis, state what would falsify it, build something capable of producing an answer I do not control, and find out my model was wrong, that is useful. Possibly more useful. It tells the reader something, it changes the artifact, and it often exposes a better question than the one I started with.&lt;/p&gt;

&lt;p&gt;I have a line in my current content plan that says missing a publishing day beats manufacturing a weak post. Nine years ago I am not sure I would have been comfortable with that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nine years later
&lt;/h2&gt;

&lt;p&gt;I am still working this out. I still publish things and wonder whether anyone will care. I still occasionally write something I expect to perform well and watch it vanish. I still publish something on a whim and find it landed exactly where it needed to.&lt;/p&gt;

&lt;p&gt;The difference is that I now have more ways to recognise success when it shows up wearing something other than a large number.&lt;/p&gt;

&lt;p&gt;A successful post might reach 30,000 people. It might produce a conversation that changes the next article. It might expose a flaw in an architecture. It might give someone language for a problem they were already having. It might lead to code. It might turn out to be part of a body of work whose shape I could not see when I wrote the first piece.&lt;/p&gt;

&lt;p&gt;And sometimes it might simply be an article I wanted to write, written well enough that I am still happy to have my name on it years later.&lt;/p&gt;

&lt;p&gt;Views measure views. They are a real measurement of a real thing, and they do not independently measure rigour, usefulness, influence, audience fit, changed behaviour, changed artifacts, or whether the writing moved me toward a better question. Sometimes those correlate. Sometimes they do not. Mine told me almost nothing for nine years, and the twenty-one thousand followers told me less.&lt;/p&gt;

&lt;p&gt;The useful question is not &lt;strong&gt;"Was this post successful?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It is &lt;strong&gt;"What did I want this post to do, and what happened because I published it?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Those are much harder numbers to put on a dashboard. I think they are also the ones worth learning to notice.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Thanks to &lt;a href="https://dev.to/sylwia-lask"&gt;Sylwia Laskowska&lt;/a&gt; for the conversation that prompted this, and for the encouragement to write some of it down.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>career</category>
      <category>writing</category>
      <category>devjournal</category>
      <category>meta</category>
    </item>
    <item>
      <title>The Most Useful Line on Your AI Cost Report Is the One You Can't Explain</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Thu, 01 Oct 2026 13:16:00 +0000</pubDate>
      <link>https://dev.to/kenwalger/the-most-useful-line-on-your-ai-cost-report-is-the-one-you-cant-explain-195f</link>
      <guid>https://dev.to/kenwalger/the-most-useful-line-on-your-ai-cost-report-is-the-one-you-cant-explain-195f</guid>
      <description>&lt;p&gt;&lt;em&gt;Attribution, allocation, and why "unknown" belongs in the schema.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This piece grew out of a comment thread on Sarvar Nadaf's &lt;a href="https://dev.to/sarvar_04/per-agent-cost-tracking-for-multi-agent-ai-on-aws-10eg"&gt;Per-Agent Cost Tracking for Multi-Agent AI on AWS&lt;/a&gt;. The schema below was worked out in that conversation, in public, and it is better for it. Where a specific idea came from the exchange, I have tried to say so.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Most AI cost dashboards answer one question well: how much did this run cost. Total tokens, model spend, per-agent spend, latency, tool usage. Those are real and useful numbers, and for a while they are enough.&lt;/p&gt;

&lt;p&gt;They stop being enough the moment your system becomes a composition. Once a request flows through a retriever, a knowledge graph, three specialist agents, and a supervisor that synthesizes their output, the total tells you almost nothing about what to change. A run can be correct, return HTTP 200, look healthy in every latency-and-errors panel, and still cost forty percent more than an identical run that produced the same answer. The overspend is real. It is just not anywhere you are looking.&lt;/p&gt;

&lt;p&gt;To find it, you have to stop asking where the money was spent and start asking what caused it to be spent. Those are different questions, and the gap between them is the whole subject of this piece.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a Cost Is Incurred Is Not What Caused It
&lt;/h2&gt;

&lt;p&gt;Consider a retrieval operation that costs $0.004: searching, ranking, fetching. That is the direct cost, and it is easy to attribute. It happened on that span, you can measure it, done.&lt;/p&gt;

&lt;p&gt;Now suppose that retrieval returned 20,000 tokens, and all of them were hydrated into a supervisor's context on the next step. The supervisor then costs $0.009. How much did the retrieval really cost?&lt;/p&gt;

&lt;p&gt;The direct answer is still $0.004. But that is no longer the interesting answer, because the retrieval also caused cost somewhere else. It inflated the supervisor's context, and some portion of that $0.009 exists only because the retrieval handed it too much material. The cost was incurred at the supervisor. It was caused, in part, at the retriever.&lt;/p&gt;

&lt;p&gt;This gives you two distinct dimensions, and a useful cost model has to carry both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Where the cost was incurred. This is just the span. Directly observed, low ambiguity.&lt;/li&gt;
&lt;li&gt;What caused or contributed to it. This is the interesting axis, and it is the one no aggregate dashboard shows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A record that captures both might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;span_id: retrieval-104
direct_cost: $0.0040
primary: retrieval
returned_tokens: 20000

downstream:
  span_id: supervisor-105
  attributed_cost: $0.0021
  caused_by: retrieval-104
  attribution_method: proportional
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dollars are single-counted. We do not charge the $0.0021 twice. The supervisor genuinely incurred it; the retrieval genuinely contributed to causing it; and the record says both without inventing money. What we have added is lineage: a link from a downstream cost back to the decision that helped produce it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hard Part Is Honesty About How You Know
&lt;/h2&gt;

&lt;p&gt;Here is the question that breaks naive versions of this: how do we know retrieval-104 actually caused $0.0021 of the supervisor's cost, and not some other amount?&lt;/p&gt;

&lt;p&gt;Sometimes you can measure it. If you have a controlled comparison where the only meaningful change is that retrieval result, the delta is real evidence. Supervisor costs $0.006 without the retrieved material and $0.009 with it, so roughly $0.003 of downstream cost is attributable to that retrieval. That is measured causation, and it is the strongest claim you can make.&lt;/p&gt;

&lt;p&gt;Most production traces do not give you that. In a real run the supervisor is carrying system instructions, conversation state, the outputs of other agents, tool results, and the retrieved material, all at once. There is no clean counterfactual. So you fall back on allocation: split the supervisor's context cost proportionally by the tokens each source contributed. That is a reasonable method. It is not measurement, and the receipt must not pretend it is.&lt;/p&gt;

&lt;p&gt;This is why the single most important field in the whole schema is not a dollar amount. It is this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;attribution_method:
  - measured_delta
  - proportional
  - estimated
  - unknown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That field is what keeps the entire model honest. It stops a proportional guess from masquerading as measured causation. With it, a line can say "retrieval span 104 contributed an estimated $0.0021 of downstream context cost, allocated proportionally by hydrated token share," and every word in that sentence is defensible, because the method is stated. Without it, the same $0.0021 acquires a precision the evidence never earned.&lt;/p&gt;

&lt;p&gt;Resist collapsing this into a confidence score. A number like &lt;code&gt;confidence: 0.82&lt;/code&gt; feels rigorous and gives you nothing, because now you have a second number whose provenance you have to go investigate. &lt;code&gt;measured_delta&lt;/code&gt;, &lt;code&gt;proportional&lt;/code&gt;, &lt;code&gt;estimated&lt;/code&gt;, and &lt;code&gt;unknown&lt;/code&gt; each tell you why you are entitled to believe the figure. The method is the provenance. A score would hide it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Show the Method Where the Decision Is Made
&lt;/h2&gt;

&lt;p&gt;A natural instinct is to keep the attribution method as drill-down metadata, out of the main view, so the report stays clean. That instinct is wrong, and it is wrong for the same reason aggregate dashboards are wrong: it makes two different claims look equivalent.&lt;/p&gt;

&lt;p&gt;The method belongs inline, next to any attributed cost, with one sensible exception. A directly observed cost carries no ambiguity and needs no method tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;retrieval-104   RETRIEVAL   $0.0040
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is nothing to disclose there; it was measured on the span. But the moment a number is attributed rather than observed, the method has to ride along:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;retrieval-104 -&amp;gt; downstream CONTEXT   $0.0021   proportional
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop the word &lt;code&gt;proportional&lt;/code&gt; and that $0.0021 visually becomes as solid as the $0.0040 above it, which is a lie of formatting. A report that hides the distinction between what it measured and what it allocated has committed the same sin as the dashboard that only shows a total. If two numbers make materially different claims, the interface must not make them look the same.&lt;/p&gt;

&lt;p&gt;So the main report shows amount, category, direct versus downstream, and method. The drill-down holds the evidence behind the method: hydrated token counts, comparison runs, parent-child span references, the assumptions the allocation rests on. The decision surface stays readable; the receipt is one click away, not dumped into the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Unknown" Is Not a Gap in the Accounting
&lt;/h2&gt;

&lt;p&gt;Every honest version of this schema has to allow &lt;code&gt;unknown&lt;/code&gt; as a real value, not a placeholder you feel bad about. And once you sit with it, the &lt;code&gt;unknown&lt;/code&gt; rows turn out to be the most useful rows in the report.&lt;/p&gt;

&lt;p&gt;A high downstream cost with &lt;code&gt;unknown&lt;/code&gt; attribution is not incomplete bookkeeping. It is the system telling you exactly where your observability boundary stops letting you explain its own behavior. It is pointing at the place where you cannot yet answer "what caused this," which is the place most worth instrumenting next. A tidy report with no &lt;code&gt;unknown&lt;/code&gt; rows has usually not achieved understanding. It has hidden its ignorance behind confident allocation.&lt;/p&gt;

&lt;p&gt;There is also a real reason &lt;code&gt;unknown&lt;/code&gt; is sometimes the only honest answer: context is not additive. An extra 5,000 tokens of context does not simply add a proportional slice of cost. It can change caching behavior, alter the reasoning path the model takes, or change how much output the model generates downstream. When that happens, token share and cost share stop mapping to each other cleanly, and any proportional number you report is a polite fiction. In those cases the schema should say &lt;code&gt;unknown&lt;/code&gt; and mean it, rather than allocate a figure it cannot defend.&lt;/p&gt;

&lt;p&gt;Read that way, the report stops being a statement of where you paid and becomes a map of two things at once: what you can explain about your spending, and where your ability to explain it runs out. The second map is the one that tells you what to build.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Actually Costs to Build
&lt;/h2&gt;

&lt;p&gt;The reason this is not a research project is that most of the structure already exists. If your traces are parent-child spans, the causal lineage is physically present already; a downstream cost sits under the decision that produced it. You are not inventing a new tracing mechanism. You are making the attribution semantics explicit on top of a trace you already record.&lt;/p&gt;

&lt;p&gt;Concretely, that is a small number of additions. Stamp a primary category on each span. Add a &lt;code&gt;contributes_to&lt;/code&gt; link and a &lt;code&gt;cause&lt;/code&gt; on spans that produce downstream effects. Add the &lt;code&gt;attribution_method&lt;/code&gt; on any attributed cost. Then roll the report up along two axes, category and direct-versus-downstream, and let &lt;code&gt;unknown&lt;/code&gt; be a first-class row rather than a swept-under one. The intelligence is not in the plumbing. It is in having the honest method field and being willing to publish the &lt;code&gt;unknown&lt;/code&gt; rows.&lt;/p&gt;

&lt;p&gt;One warning from the same conversation that produced all this: how you record and how you attribute are coupled. Change the way spans are emitted and you can silently break the logic that reads them. The defense is the same one that makes the whole model trustworthy, a known-good baseline you compare against, so that when your instrumentation shifts under you, the numbers move and you notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Point
&lt;/h2&gt;

&lt;p&gt;We have spent a lot of effort making AI spend visible. Better token counts, per-agent breakdowns, nested traces. All of it answers "how much." Almost none of it answers "why," and "why" is the only version of the question you can act on.&lt;/p&gt;

&lt;p&gt;The move from one to the other is not a bigger dashboard. It is a small, honest schema: separate where a cost was incurred from what caused it, state the method behind every attributed number, and treat the costs you cannot explain as signal rather than embarrassment. Do that, and the report stops telling you what you spent and starts telling you what to fix, including, in the &lt;code&gt;unknown&lt;/code&gt; rows, where to look first.&lt;/p&gt;

&lt;p&gt;Even the cost report, it turns out, needs provenance. Not just the amount and the category, but how sure you are about who to blame. That last column may be the most useful one on the page.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;With thanks to Sarvar Nadaf, whose post and the conversation under it produced this schema, and to the commenters in that thread who pushed on the baseline and the propagation. The receipt is better for the argument.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>observability</category>
      <category>agents</category>
      <category>provenance</category>
    </item>
    <item>
      <title>A wine cellar that remembers what you used to believe</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Wed, 30 Sep 2026 22:49:21 +0000</pubDate>
      <link>https://dev.to/kenwalger/a-wine-cellar-that-remembers-what-you-used-to-believe-4i55</link>
      <guid>https://dev.to/kenwalger/a-wine-cellar-that-remembers-what-you-used-to-believe-4i55</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/sanity-2026-09-16"&gt;Sanity Challenge, Path Two: Vibe-Code Something Strange&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cellar&lt;/strong&gt; is a wine collection that remembers what you used to believe.&lt;/p&gt;

&lt;p&gt;Most cellar apps answer one question: what do I have right now? Cellar answers a harder one. What did my cellar look like on 1 June 1999? What did I think that bottle's drinking window was in 2018, before a later tasting changed my mind? Which bottles were at peak last year while I wasn't paying attention, and are now past saving?&lt;/p&gt;

&lt;p&gt;Those questions rule out the obvious data model. A &lt;code&gt;status&lt;/code&gt; field on a bottle tells you what is true today and nothing about 2004. A &lt;code&gt;drinkUntil&lt;/code&gt; field on a wine tells you what someone currently believes and destroys what they believed before.&lt;/p&gt;

&lt;p&gt;So Cellar stores neither. It stores events and claims:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Acquisitions&lt;/strong&gt; and &lt;strong&gt;consumptions&lt;/strong&gt; are documents with dates, not fields on a bottle. A bottle is consumed as of date T if a consumption event exists on or before T.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assessments&lt;/strong&gt; are dated, attributed claims about when a wine should be drunk. Nothing overwrites a window. The current window is resolved from the claims that existed at T, by explicit rules: my own tasting note outranks the producer's, which outranks a critic's, and within a tier the most recent claim wins.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else follows. Move a date control backward and acquisitions disappear, consumptions reverse, and drinking windows change as the controlling claim changes. Move it forward and unopened bottles age through their windows.&lt;/p&gt;

&lt;p&gt;The design rule underneath: &lt;strong&gt;do not store what is true now when you may later need to know what was true then.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There are three surfaces, with different jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sanity Studio&lt;/strong&gt; authors and governs the ledger. Six document types, a custom review queue, two document actions, and a read-only workflow field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Sanity App&lt;/strong&gt;, built on the App SDK and running inside Sanity, interprets it. Three views, all computed live from the event log by a pure TypeScript package that knows nothing about Sanity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A public page&lt;/strong&gt; is that same App without an account. Read-only by construction rather than by convention, since there is no token in the bundle and the dataset's public ACL grants reads only.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it deliberately does not have is a second CRUD interface. Studio already is one, so adding bottles and recording consumptions happens there. The App interprets; it does not manage.&lt;/p&gt;

&lt;p&gt;Who it is for, honestly: me. But the argument generalizes past wine, which is the reason I built it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Open it with no account: &lt;a href="https://kenwalger.github.io/Cellar/" rel="noopener noreferrer"&gt;kenwalger.github.io/Cellar&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inside Sanity&lt;/strong&gt; (organization members): &lt;a href="https://www.sanity.io/@opyntsvcl/application/gcxu5htdwn5n9lc15m64sfpp" rel="noopener noreferrer"&gt;The Cellar app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/bb-9b_9k7WA" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;The same 542-bottle ledger, read at three moments:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;In cellar&lt;/th&gt;
&lt;th&gt;Drinking&lt;/th&gt;
&lt;th&gt;Past window&lt;/th&gt;
&lt;th&gt;Not yet acquired&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 June 1999&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;536&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23 September 2026&lt;/td&gt;
&lt;td&gt;248&lt;/td&gt;
&lt;td&gt;166&lt;/td&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;23 July 2035&lt;/td&gt;
&lt;td&gt;248&lt;/td&gt;
&lt;td&gt;62&lt;/td&gt;
&lt;td&gt;182&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In 1999 the whole cellar is four bottles of 1993 Château Mouton Rothschild, with two already opened. By 2026 it is 248 bottles. Projected to 2035, those same 248 bottles are still there, and 182 of them have gone past window.&lt;/p&gt;

&lt;p&gt;The cellar never loses bottles. It loses opportunities.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Past:&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F02psjha2oavwao358zcz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F02psjha2oavwao358zcz.png" alt="The Cellar at 1 June 1999: 4 bottles in the cellar, 536 not yet acquired" width="634" height="844"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Present:&lt;/strong&gt; &lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsj80i2zypphhazeu8vup.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsj80i2zypphhazeu8vup.png" alt="The Cellar today: 248 bottles in the cellar, 166 drinking, 33 past window" width="633" height="837"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Future:&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhaqgcue8e7wehcyduo4o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhaqgcue8e7wehcyduo4o.png" alt="Projected to 23 July 2035: still 248 bottles, 62 drinking, 182 past window, with a note that the projection assumes no further acquisitions, consumptions or assessments" width="624" height="856"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Drink Soon lists what closes within twelve months, and every row names the claim that decided it: which tier, whose, and when.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F36jgg3jcsna47d6060cl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F36jgg3jcsna47d6060cl.png" alt="Drink Soon at today's date: 23 bottles across 12 wines, each row showing the source tier, the source name, the assessment date and the number of accepted claims" width="800" height="1034"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Missed Opportunities asks a different question: which bottles were at peak during a period, were never opened, and are past window now.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgzfi2tpk1ql2w64egmfm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgzfi2tpk1ql2w64egmfm.png" alt="Missed Opportunities for 2023: 17 bottles across 9 wines, of which 8 did get opened in time" width="800" height="1053"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And when nothing was lost, it says why rather than showing an empty panel.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj732v0wsr80r1mz43f1a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj732v0wsr80r1mz43f1a.png" alt="The empty state for 2019: nothing was lost in this period, with 47 bottles at peak, 32 since opened, and 7 still in hand and not past window" width="800" height="223"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three views in the App, all computed live from the event log:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cellar health&lt;/strong&gt;, bottle counts by state at any date&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drink Soon&lt;/strong&gt;, windows closing within twelve months, each row naming which claim decided it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missed Opportunities&lt;/strong&gt;, bottles that were at peak during a period, were never opened, and are past window today&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And in Studio, the &lt;strong&gt;review queue&lt;/strong&gt;, where a proposed claim waits for a person.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/kenwalger/Cellar" rel="noopener noreferrer"&gt;github.com/kenwalger/Cellar&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  My Build Process
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Tools:&lt;/strong&gt; Claude Code in a terminal beside WebStorm, with Sanity's MCP server connected so the model could check current documentation rather than recall it. One thing worth stating plainly: the prompts themselves were drafted in a separate planning conversation with Claude in the chat app, where I also made the decisions at each fork before relaying them. Calling this one developer and one agent would be inaccurate.&lt;/p&gt;

&lt;p&gt;The project entered implementation with fourteen planning documents: a content model, a temporal resolution specification, a build plan, a seed data plan, and eleven architecture decision records. None of it was code. That turned out to matter, though not in the way I expected.&lt;/p&gt;

&lt;h3&gt;
  
  
  The pattern that emerged
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;specification
     ↓
agent conflict pass, no code written
     ↓
human decision
     ↓
implementation
     ↓
independent oracle, measurement, or experiment
     ↓
gate only I can close
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every stage started with a prompt that said, roughly: read the specs, check them against the current platform, report where they disagree, and stop. Every stage found something. The count went &lt;strong&gt;up&lt;/strong&gt; as the project went on, not down. Stage 1 found three spec errors. Stage 4 opened with ten conflicts, two of them spec errors.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prompts that worked
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;"Report conflicts and stop before writing code."&lt;/strong&gt; The single highest-value instruction in the project. In Stage 1 it found that I had specified underscore-prefixed projection fields, which Sanity reserves for system fields, and a validation rule requiring an assessment to have been &lt;em&gt;created&lt;/em&gt; in a particular state, which Sanity cannot express because validation sees current state only. Implementing that rule literally would have made the accept transition permanently invalid, breaking the exact workflow the rule existed to protect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gates that name what does not count as proof.&lt;/strong&gt; For the App SDK gate I wrote out the path being tested and then listed the ways it could be faked:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Do not satisfy this gate by calling &lt;code&gt;@cellar/core&lt;/code&gt; separately from the App, querying the dataset through another tool, or relying only on the existing test suite. The purpose is to prove the complete path: Content Lake → App SDK → CELLAR_QUERY → toCellarSnapshot → bottleState() → rendered view.&lt;/p&gt;

&lt;p&gt;You cannot see the rendered view, so do not report the gate as passed. Give me the command, the URL, and exactly what I should see.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That last paragraph matters more than the first. Claude Code has no browser, and a model asked to confirm something it cannot observe is under pressure to confirm it anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"State the expected result before deploying."&lt;/strong&gt; For the Functions experiment, the probe had to produce one predetermined log line, written down and approved before anything was deployed. A bundler can resolve an import and still ship a shimmed or stale module; a log line proves nothing on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Oracles, with an explicit instruction not to repair them.&lt;/strong&gt; Expected outputs were generated before implementation and the prompt said to treat them as an oracle rather than as output to be fixed. When a test disagrees, the cheapest move for a model is to adjust the fixture.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prompts that didn't, and where I was wrong
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;I proposed a test that would have written to production.&lt;/strong&gt; Setting up the Functions probe, I suggested scoping the trigger at the production dataset with a filter on a document type that doesn't exist, to avoid creating a scratch dataset. Claude Code declined, correctly: scoping is safe, invoking is not. A document Function can only be invoked by writing a matching document, so my plan would have put two mutations permanently in production's history to avoid a dataset that deletes whole.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I told it to give up on a test I assumed was impossible.&lt;/strong&gt; After finding a test that passed while measuring nothing, I said that if the clause couldn't be isolated, it should fall back to a weaker assertion. It ran the experiment instead and found a fixture, on the realization that &lt;code&gt;now&lt;/code&gt; in this system is a parameter rather than the clock. "As of June 2024, what had I missed that year?" is an ordinary question for a time machine, and it makes the case constructible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I approved a feature without asking what it cost.&lt;/strong&gt; An extra column showing "3 of 5 at peak, 1 opened in time" came back with a measurement I hadn't requested: it costs about four times what the rest of the view does, because it scans every other bottle of every listed wine. Still 5% of a frame, so nothing changed, but the earlier estimate had been for something narrower than what shipped and it said so.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the model got stuck
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;It confidently misread an API.&lt;/strong&gt; It reported that Sanity previews cannot follow references and deferred a feature to a later stage. Asked to verify against the docs rather than assert, it found the section demonstrating exactly that, said plainly it had been wrong, and fixed it in one line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It nearly filed a bug that didn't exist.&lt;/strong&gt; It concluded that installed type definitions contradicted the reference docs about where &lt;code&gt;perspective&lt;/code&gt; lives. They don't: the option is inherited through an interface chain that the reference flattens and the &lt;code&gt;.d.ts&lt;/code&gt; doesn't. It caught this itself and logged the near-miss instead of the finding. That's the more useful record, because a developer who files bugs that turn out to be their own misreading loses credibility fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Right conclusion, wrong reasoning.&lt;/strong&gt; It argued for inclusive date comparison on the grounds that strict comparison would reject ordinary windows in the seed data. It wouldn't, because same-year windows normalize to 1 January and 31 December, which are strictly ordered. The conclusion was right anyway. A model that is right for the wrong reason is right only until the case changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It asserted a gitignore rule instead of checking it.&lt;/strong&gt; A stage list said the public build's output directory was already ignored. It wasn't: a bare &lt;code&gt;dist&lt;/code&gt; pattern matches a directory named &lt;code&gt;dist&lt;/code&gt;, and this one is &lt;code&gt;dist-public&lt;/code&gt;. Six build files were committed, in the same commit whose accompanying document argued that build output does not belong in a repository. The cause is neat: &lt;code&gt;dist-public&lt;/code&gt; was named that way specifically so it could not collide with &lt;code&gt;dist/&lt;/code&gt;, and a name chosen to be distinct from &lt;code&gt;dist&lt;/code&gt; is by construction one that &lt;code&gt;dist&lt;/code&gt; does not match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It broke an explicit rule twice, and disclosed both times.&lt;/strong&gt; I told it not to run git commands that change the repository. Twice it ran &lt;code&gt;git checkout --&lt;/code&gt; to undo its own uncommitted edits, and twice it reported this unprompted before I found it. The rule now says explicitly that the file tools are the only way to revert anything, including its own work.&lt;/p&gt;

&lt;h3&gt;
  
  
  How I course-corrected
&lt;/h3&gt;

&lt;p&gt;Mostly by making verification harder to fake rather than by writing better instructions. The gates got stricter as the project went on: name the path, exclude the proxies, state the expected numbers in advance, and put the confirmation in the hands of the one participant with eyes.&lt;/p&gt;

&lt;p&gt;The other correction was measurement. Three of the four real bugs in this project were invisible to every check designed for the feature they lived in.&lt;/p&gt;

&lt;h3&gt;
  
  
  The App SDK
&lt;/h3&gt;

&lt;p&gt;The gate was one view rendering live production data inside Sanity, with &lt;code&gt;now&lt;/code&gt; frozen to a date an independent script had already computed. The numbers had to match exactly: hold 45, drinking 166, past window 33, unassessed 4, consumed 294, 542 total. They did, first try, and I confirmed them in a browser because the model couldn't.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;useQuery&lt;/code&gt; subscribes, so publishing an assessment in Studio moves the counts in the App with no synchronization layer. Studio edits the graph, the App reads the graph, GROQ traverses it, Functions react to it, and a pure module decides what it all means.&lt;/p&gt;

&lt;p&gt;Learning the current App SDK was harder than using it. In one session: Sanity's own bundled agent rule supplied a CLI command with a flag the installed CLI rejects; the quickstart named a template the CLI reference doesn't list; and that template installed the SDK a major version behind the docs I was being pointed at. All three are stale vendor guidance rather than product defects, which is a distinction worth making and also a pattern worth reporting.&lt;/p&gt;

&lt;p&gt;The deeper issue was a recurring shape. The documented path is well covered for the common case of rendering and editing documents. Cellar computes an aggregate, and the guidance steered toward &lt;code&gt;useDocuments&lt;/code&gt; and &lt;code&gt;useDocumentProjection&lt;/code&gt;, which for six numbers would mean roughly 1,500 hook instances and round trips instead of one query. Similar story with &lt;code&gt;useState&lt;/code&gt;: the rule correctly warns against holding Content Lake values there, and says nothing about ephemeral view state like a date control. Neither is wrong. But once you step outside the example app, it becomes difficult to tell &lt;strong&gt;not recommended&lt;/strong&gt; from &lt;strong&gt;not documented&lt;/strong&gt; from &lt;strong&gt;not supported&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Workflows
&lt;/h3&gt;

&lt;p&gt;An assessment carries a review state: proposed, accepted, or rejected. Only accepted claims resolve windows. An agent proposes; a person decides; rejected claims stay in the dataset, because deleting them would destroy exactly the record this project exists to keep.&lt;/p&gt;

&lt;p&gt;Two details I'd defend:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The review state is read-only in the form.&lt;/strong&gt; A workflow you can bypass with a radio button is decoration. The two document actions are the only path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An accepted proposal records that a model extracted it.&lt;/strong&gt; Same tier and same author as a hand-written claim, because accepting it makes it yours, but with &lt;code&gt;sourceMethod: extracted&lt;/code&gt; alongside. A system arguing that claims should carry their provenance cannot then be unable to say whether a person wrote this one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz5x46rrqewsnllsdkckm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz5x46rrqewsnllsdkckm.png" alt="A proposed assessment in the Studio: review state read-only and set to proposed, source method extracted, a reference back to the consumption it came from, and a notes field quoting the phrase the window was drawn from" width="800" height="1364"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The agent half.&lt;/strong&gt; A document action on a consumption sends the tasting note, the date, and the wine to Sanity's Prompt API and writes back a proposed assessment. It abstains when the note carries no temporal signal, and abstention writes no document at all rather than a proposal with empty bounds, because a proposal nobody can accept is noise in a review queue.&lt;/p&gt;

&lt;p&gt;I checked it against twelve real tasting notes from the dataset, asserting direction rather than exact years, since direction is checkable on a non-deterministic reply. Twelve for twelve, and the outputs were better than the pass rate suggests. Every non-abstaining reply quotes the phrase it relied on, which is what makes the claim auditable: you can see which five words produced a window. Confidence tracks how directly the note states the thing, high on "This is the peak" and low on "Fruit starting to dry out at the edges", which is a hedge in my own note. And on "Could have waited another two years" from a 2024 opening it returned 2026, which is arithmetic rather than classification.&lt;/p&gt;

&lt;p&gt;The abstentions are the part I did not expect. They name what the note actually contains: "the note only describes the occasion for opening the bottle and gives no signal about whether the wine was too young, drinking well, or past its best."&lt;/p&gt;

&lt;p&gt;One honest result. Hunting for a proposal that would visibly move the counts turned into a search, because the seed data carries an assessment on nearly every wine, so the cases where an agent's claim wins outright are the leftovers. The first candidate lost on recency within its own tier: a personal claim of mine from 2018 outranked a proposal dated 2017. &lt;strong&gt;The agent's proposals mostly did not change the cellar, because I had usually already assessed the wine myself.&lt;/strong&gt; That is the authority rule working, and editing the seed data to get better footage was considered for about a line, then rejected. It is the dataset three oracles were built against.&lt;/p&gt;

&lt;p&gt;I verified the workflow against a staging copy of production rather than in a unit test. Two claims written in, neither moving a single count while proposed. One rejected, still moving nothing, though accepting it would have moved five bottles and emptied a wine out of Missed Opportunities. One accepted, moving exactly the five bottles predicted a day earlier.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I cut, and why
&lt;/h3&gt;

&lt;p&gt;One planned piece did not get built: a Function maintaining &lt;code&gt;derived&lt;/code&gt; fields on wines and bottles, so the current state of each would be stored rather than computed.&lt;/p&gt;

&lt;p&gt;The reason is that a projection is a cache, and the thing it would cache costs 0.07 milliseconds for all 542 bottles. Measured, not assumed. The App reads the event log directly and recomputes on every date change, which is under half a percent of a frame budget.&lt;/p&gt;

&lt;p&gt;The more interesting reason is that a projection cannot do the thing this application exists to do. A stored state is true as of one date, which ADR 0012 forced into the open: any clock-dependent projection has to store the date it was computed for, beside the value. Nothing precomputed answers "what was the cellar on 1 June 1999" unless you precompute every date, and that is not a cache, it is a different data structure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What would change at scale&lt;/strong&gt;, since "it is fast enough for 542 bottles" is not an architecture:&lt;/p&gt;

&lt;p&gt;The arithmetic is not what breaks first, and the numbers say so. The ledger is 211.9 KB today, about 400 bytes per bottle:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Today&lt;/th&gt;
&lt;th&gt;x100&lt;/th&gt;
&lt;th&gt;1.5M bottles&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Query payload&lt;/td&gt;
&lt;td&gt;211.9 KB&lt;/td&gt;
&lt;td&gt;20.7 MB&lt;/td&gt;
&lt;td&gt;572.6 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full state tally&lt;/td&gt;
&lt;td&gt;0.07 ms&lt;/td&gt;
&lt;td&gt;~7 ms&lt;/td&gt;
&lt;td&gt;~200 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fetch becomes impractical somewhere around fifty thousand bottles, two orders of magnitude before the scan starts dropping frames. A projection would not have helped with the half that breaks first. The fix is scoping the query, by producer or vintage or a search, and a scoped query already existed in the codebase before the Function was proposed.&lt;/p&gt;

&lt;p&gt;Where a Function would genuinely earn its place is a different job. A projection is not only a cache, it is also what makes state &lt;strong&gt;queryable&lt;/strong&gt;. Expressing authority-then-recency resolution in GROQ is possible, and my own spec describes it as possible and unreadable, needing a rewrite the first time a tier is added. So "show me every bottle past its window" would mean a second implementation of the load-bearing rule, in a second language, in a place the tests do not reach. That is why two of the Studio polish items were blocked on this Function, and it is a reason that applies at 542 bottles, not only at a million.&lt;/p&gt;

&lt;p&gt;I still cut it, because nothing in the demo reads those fields and the writing was not done. But "we did not need the cache" is the smaller half of the answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Getting it in front of someone without an account
&lt;/h3&gt;

&lt;p&gt;This took four prompts to settle and produced the finding I'd most want Sanity to see.&lt;/p&gt;

&lt;p&gt;A deployed App SDK app cannot be shown to anyone outside your organization. The Dashboard gates on membership, and a changelog entry lists cross-organization visibility as a bug that was &lt;em&gt;fixed&lt;/em&gt;. Hosting the app statically doesn't help either: &lt;code&gt;AuthBoundary&lt;/code&gt; redirects a logged-out visitor to a login page before a single query is issued. Asked whether any supported configuration satisfies that boundary without a user token, the model enumerated the entire typed auth surface rather than sampling it. Every route resolves to a user token. The SDK recognizes two environments, a Dashboard iframe and a Studio, and a static host is not one of them.&lt;/p&gt;

&lt;p&gt;There is exactly one typed field that would make a static page render by asserting it is a Studio. It was recorded as a near-miss rather than recommended, on three documented grounds including that it asserts something false.&lt;/p&gt;

&lt;p&gt;So the public page drops the App SDK and reads the public dataset with a plain client. That touches one file, because everything downstream takes a &lt;code&gt;Cellar&lt;/code&gt; object and knows nothing about Sanity, and the views are shared rather than forked.&lt;/p&gt;

&lt;p&gt;Then the last surprise. &lt;strong&gt;A public dataset is exempt from authentication, not from CORS.&lt;/strong&gt; An unauthenticated read from a &lt;code&gt;github.io&lt;/code&gt; origin returns 403 until the origin is allowlisted. Every anonymous read this project had done went through curl, and curl sends no &lt;code&gt;Origin&lt;/code&gt; header, so no CORS check had ever applied. Three sessions had verified the dataset was readable using a tool that structurally cannot test the thing that mattered. The failure would have been invisible until the page went live, because localhost was already allowlisted.&lt;/p&gt;

&lt;p&gt;Three distinct access-control mechanisms, then, to get one page working: dataset ACL, organization membership, and origin allowlisting. None of them is discoverable from the surface the other two are configured on.&lt;/p&gt;

&lt;h3&gt;
  
  
  The answer was in the installed code, repeatedly
&lt;/h3&gt;

&lt;p&gt;A pattern worth naming on its own, because it decided four separate questions.&lt;/p&gt;

&lt;p&gt;Two doc pages contradict each other about whether the Prompt action requires a &lt;code&gt;schemaId&lt;/code&gt;. Session 8 concluded that one of them is wrong and that reading more carefully would not reveal which. The installed type file reveals which in about a minute: &lt;code&gt;PromptRequestBase&lt;/code&gt; declares four members, &lt;code&gt;schemaId&lt;/code&gt; is not among them, and it is required four times on the other action types in the same file. The troubleshooting page is not merely wrong, it is backwards. Omitting the field is correct; including it is the type error.&lt;/p&gt;

&lt;p&gt;That finding deleted a step I was about to take. A schema deploy had sat on the plan for five days as a production write, purely as insurance against a contradiction the type file settles.&lt;/p&gt;

&lt;p&gt;The same thing happened with authentication. The HTTP reference says every endpoint requires bearer auth and says nothing about which clients satisfy it. The answer turned out to be in the AI Assist custom field actions guide, which is not a page about authentication and which demonstrates the pattern twice in complete examples with no token anywhere. The authoritative page created the difficulty; the page that resolves it does so incidentally, by example, with no sentence anywhere stating the fact. That removed the second planned production write.&lt;/p&gt;

&lt;p&gt;Add the App SDK auth enumeration and the Functions bundling probe and it is four times in two weeks that the shipped code answered a question the documentation could not. The documentation is not bad. It is describing a platform moving faster than it can.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two more findings I'd hand to Sanity
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Functions bundling is documented as a project-wide choice and isn't.&lt;/strong&gt; The docs describe TypeScript Functions in pnpm workspaces and non-TypeScript npm projects. This repo is npm plus TypeScript, which is neither. Rather than read more, I deployed a throwaway probe with one predetermined expected result. It worked first try, and inspecting the bundle explained why: the CLI inlined and tree-shook the local workspace package while externalizing the registry dependency. It decides per dependency, not per project. A private workspace package never has to be installable, only resolvable at build time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Function that does nothing gets an editor token.&lt;/strong&gt; The probe constructed no client, ran no queries and wrote nothing. Deployment still provisioned a robot token with editor role and no expiry.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the AI actually contributed
&lt;/h3&gt;

&lt;p&gt;Not mainly speed. Probably faster, but that's the least interesting result.&lt;/p&gt;

&lt;p&gt;It found errors in my specifications. It introduced its own bugs and caught some of them. It used mutation testing twice, unprompted, to prove that tests fail when the code they cover is removed. It refused to fabricate data when the ledger had no column for a link it was asked to infer, and that refusal exposed that one of my own verification scripts was passing on a proxy.&lt;/p&gt;

&lt;p&gt;The conclusion I'd draw is narrower than "specs make AI-generated code trustworthy":&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Specifications make disagreement observable. Independent witnesses make that disagreement useful.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sometimes the witness is a CSV generated before implementation. Sometimes a second algorithm, a property test over 17,167 inputs, a benchmark, or the contents of a deployed bundle. Sometimes it's me reading six numbers off a screen, because the model cannot see them.&lt;/p&gt;

&lt;p&gt;Four examples of why that matters more than green tests:&lt;/p&gt;

&lt;p&gt;A date bug in the time control got January 31 to March 1 wrong. &lt;strong&gt;All five of my verification dates passed under the buggy version.&lt;/strong&gt; The application worked, the screenshots were right, and every date I planned to demo was fine. A property test across every slider position found it.&lt;/p&gt;

&lt;p&gt;A test asserting that proposed claims change nothing passed while measuring nothing, because its fixture would have lost on recency whether it was proposed or accepted. What caught it was the companion assertion that accepting must change at least one state. Auditing the rest of the suite for that shape found two more.&lt;/p&gt;

&lt;p&gt;Then the same idea from the other direction. A CI check grepping the built bundle for leaked credentials fired on every clean build, because its pattern matched a legitimate export name from &lt;code&gt;@sanity/client&lt;/code&gt;. A check that always fails gets waved through and certifies exactly as little as a check that can never fail, and it's worse, because a false positive doesn't look broken. It looks diligent.&lt;/p&gt;

&lt;p&gt;And spec-first has its own failure mode, which I ran into late. A polish list drafted from the build plan turned out to have three items already done. A plan describes work that was scheduled, not work that is left.&lt;/p&gt;

&lt;p&gt;The finding I keep coming back to is a different shape. A normalization rule written on day one, for a reason that had nothing to do with the interface, turned every drinking window into whole years. Weeks later that rule made a horizon slider useless, because the count could only change once a year. A stage after that, it made the obvious period for Missed Opportunities structurally empty, all year, every year. No document connects those three facts, and no amount of reading would have found them. Measuring the data did, twice, in about fifteen minutes each.&lt;/p&gt;

&lt;p&gt;Specifications don't only prevent errors. They reach forward and constrain decisions nobody anticipated making.&lt;/p&gt;

&lt;h2&gt;
  
  
  Beyond the cellar
&lt;/h2&gt;

&lt;p&gt;Wine makes this a pleasantly low-stakes problem. The pattern applies anywhere "what was true then?" matters alongside "what is true now?"&lt;/p&gt;

&lt;p&gt;A museum needs to know which attribution it accepted when an object was exhibited, not only which one it holds today. An HR system needs to evaluate an action against the policy in force when it happened, not the policy as amended since.&lt;/p&gt;

&lt;p&gt;The model underneath is the same in both: events record what happened, dated claims record what was believed, provenance records where those claims came from, and resolution decides what was knowable at a given moment.&lt;/p&gt;

&lt;p&gt;It is not free. You can never answer "what is the drinking window?" without also answering "as of when?", every read costs a resolution pass instead of a field lookup, and nobody can simply set a value. Most systems should store current state, because most systems are only ever asked about now. The decision worth making deliberately is whether historical truth is a requirement or an accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sanity Project Details
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Project ID:&lt;/strong&gt; &lt;code&gt;aos9nze5&lt;/code&gt; · &lt;strong&gt;Dataset:&lt;/strong&gt; &lt;code&gt;production&lt;/code&gt; (public)&lt;/p&gt;

&lt;p&gt;The dataset is world-readable, so the content model can be inspected directly. Six document types: &lt;code&gt;producer&lt;/code&gt;, &lt;code&gt;wine&lt;/code&gt;, &lt;code&gt;bottle&lt;/code&gt;, &lt;code&gt;acquisition&lt;/code&gt;, &lt;code&gt;consumption&lt;/code&gt;, &lt;code&gt;assessment&lt;/code&gt;, plus a registered &lt;code&gt;varietal&lt;/code&gt; object type. 1,645 documents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;About the data.&lt;/strong&gt; The cellar is loosely based on a real one. The producers, appellations, and club memberships are real, and some of the history is too, including the 1993 Mouton bought in 1996 and opened twice in the nineties. Everything evaluative is invented. Drinking windows, scores, critic notes, and most tasting notes exist for the demo, and critic assessments are attributed to publications that do not exist. Nothing here should be read as a factual claim about any wine, and nothing attributed to a named producer reflects anything they have actually said.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agent Session
&lt;/h2&gt;

&lt;p&gt;The curated session is here: &lt;a href="https://dev.to/agent_sessions/claude-code-session-qv3oup"&gt;Ten conflicts before a line of code&lt;/a&gt;. It is a link rather than an embed: the transcript contains 383 &lt;code&gt;@&lt;/code&gt; characters, in package names and file paths, and the editor counts each as a user mention against a limit of ten, so the post would not save.&lt;/p&gt;

&lt;p&gt;The full record is in the repository either way: fifteen session logs under &lt;a href="https://github.com/kenwalger/Cellar/blob/main/docs/friction-logs/README.md" rel="noopener noreferrer"&gt;docs/friction-logs/&lt;/a&gt;, each with the prompts and outputs verbatim, plus what was found and what it cost. If you only read one, read session 3, where ten conflicts came back before a line of code was written.&lt;/p&gt;




&lt;p&gt;Addendum: the post's own argument, pointed at the post&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/izgorodin"&gt;A reader&lt;/a&gt; noticed something after publication that I had not, and it is the best catch of the project, so it belongs here rather than in a footnote.&lt;/p&gt;

&lt;p&gt;The two questions at the top of this piece are not the same kind of question. What did my cellar look like on 1 June 1999 asks when things happened. What did I think that bottle's window was in 2018 asks when the ledger learned it. One date control answers both only while a claim starts counting on the day it is dated.&lt;/p&gt;

&lt;p&gt;In the seeded data, it always does. The import wrote all 161 assessments as accepted, with &lt;code&gt;assessedA&lt;/code&gt;t as their only date, so every date the demo can be driven to gives the same answer either way. The demo is accurate. It is accurate by accident of the import, not by design.&lt;/p&gt;

&lt;p&gt;The agent workflow is where the two come apart. A proposal is dated the day after the bottle was opened, often years ago, and acceptance happens now. On acceptance, the resolver treats the claim as having counted since that past date, so an acceptance today can change what the cellar says about 2018. The Accept dialog warns about precisely this. The resolver has no way to represent the alternative.&lt;/p&gt;

&lt;p&gt;The reason is that &lt;code&gt;reviewState&lt;/code&gt; is stored current state with no history, which is the exact shape this post spends two thousand words arguing against. A claim either counts or it does not, and nothing records when that became true. It survived twelve architecture decision records because the field reads as workflow rather than as domain data.&lt;/p&gt;

&lt;p&gt;The fix is one field: write &lt;code&gt;acceptedAt&lt;/code&gt; alongside the transition, backfill it to &lt;code&gt;assessedAt&lt;/code&gt; for the imported claims, and resolve on &lt;code&gt;assessedAt &amp;lt;= T &amp;amp;&amp;amp; acceptedAt &amp;lt;= T&lt;/code&gt;. One slider still answers both questions, because T then means the state of knowledge at T rather than what today's knowledge says about T.&lt;/p&gt;

&lt;p&gt;I am not shipping it mid-judging, and it is recorded as &lt;a href="https://github.com/kenwalger/Cellar/blob/main/docs/ADRs/0013-acceptance-time-is-not-recorded.md" rel="noopener noreferrer"&gt;ADR 0013&lt;/a&gt;. A project whose thesis is to write down what you got wrong should not stop doing that the moment it ships.&lt;/p&gt;

</description>
      <category>sanitychallenge</category>
      <category>devchallenge</category>
      <category>ai</category>
      <category>sanity</category>
    </item>
    <item>
      <title>Your Metric Is Not Your State</title>
      <dc:creator>Ken W Alger</dc:creator>
      <pubDate>Tue, 29 Sep 2026 16:13:00 +0000</pubDate>
      <link>https://dev.to/kenwalger/your-metric-is-not-your-state-2lfl</link>
      <guid>https://dev.to/kenwalger/your-metric-is-not-your-state-2lfl</guid>
      <description>&lt;p&gt;&lt;em&gt;What a refractometer taught me about observability&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.kenwalger.com/blog/ai/mcp/the-field-agent-sovereign-vineyard-mcp/?utm_source=dev_to&amp;amp;utm_medium=article&amp;amp;utm_campaign=systems_under_fermentation" rel="noopener noreferrer"&gt;In July&lt;/a&gt;, I wrote about a farmer walking a vineyard row at dawn, crushing a grape onto a refractometer prism, and typing "13.5 Brix" into a chat window. The agent on the other end didn't care how the number arrived. To the Digital Scribe, I wrote, a number is just a number.&lt;/p&gt;

&lt;p&gt;In the vineyard, that's a defensible position. Fresh grape juice is exactly what a refractometer is built to read.&lt;/p&gt;

&lt;p&gt;This fall, I've been pointing the same kind of instrument at juice that's fermenting. It turns out the agent should have cared.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three batches and a prism
&lt;/h2&gt;

&lt;p&gt;Over the last several weeks I've started three small batches: a blackberry wine that's now bulk aging, a pear wine that began fermenting on September 24, and a fireweed honey mead that got its yeast two days later. They're one-gallon batches, which matters for this story, because at that size every sample you pull is wine you don't get to drink and oxygen you've let in.&lt;/p&gt;

&lt;p&gt;So I track them with a refractometer. A few drops of liquid on a glass prism, close the cover, hold it up to the light, read a number off the scale. The number is Brix, roughly the percentage of dissolved sugar. It's fast, cheap, and costs a few drops per reading. During the blackberry's primary fermentation I took readings twice a day, every time I punched down the cap of fruit.&lt;/p&gt;

&lt;p&gt;And once fermentation starts, the refractometer is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The instrument isn't lying, exactly
&lt;/h2&gt;

&lt;p&gt;A refractometer measures how much light bends as it passes through a liquid. Dissolved sugar bends light, so in fresh juice the amount of bending is a good proxy for the amount of sugar. The scale on the instrument is calibrated on that assumption.&lt;/p&gt;

&lt;p&gt;Fermentation breaks the assumption. Yeast converts sugar into alcohol, and alcohol bends light too. A few days into primary, the refractometer is reporting the combined effect of the sugar that remains and the alcohol that's been produced, and presenting all of it as sugar. The reading comes out higher than the real sugar content. Taken at face value, it says the fermentation is further behind than it actually is.&lt;/p&gt;

&lt;p&gt;To get something usable, you run each reading through a correction formula. Most home winemakers use calculators built on formulas originally developed for brewing, and every one of them needs the same extra input: the original reading, taken before the yeast went in.&lt;/p&gt;

&lt;p&gt;Here's what that looked like on the blackberry:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Day&lt;/th&gt;
&lt;th&gt;Raw refractometer (Brix)&lt;/th&gt;
&lt;th&gt;Corrected value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;08/29/2026&lt;/td&gt;
&lt;td&gt;20.7&lt;/td&gt;
&lt;td&gt;20.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;08/30/2026&lt;/td&gt;
&lt;td&gt;18.4&lt;/td&gt;
&lt;td&gt;16.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;08/30/2026&lt;/td&gt;
&lt;td&gt;17.0&lt;/td&gt;
&lt;td&gt;14.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;08/31/2026&lt;/td&gt;
&lt;td&gt;13.2&lt;/td&gt;
&lt;td&gt;8.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;08/31/2026&lt;/td&gt;
&lt;td&gt;10.8&lt;/td&gt;
&lt;td&gt;4.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;09/01/2026&lt;/td&gt;
&lt;td&gt;8.7&lt;/td&gt;
&lt;td&gt;0.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;09/01/2026&lt;/td&gt;
&lt;td&gt;8.3&lt;/td&gt;
&lt;td&gt;0.1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nothing is malfunctioning. The instrument is doing exactly what it was built to do. What changed is the system it's pointed at, and a number that was a reliable proxy in one state became a misleading one in the next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Neither instrument measures sugar
&lt;/h2&gt;

&lt;p&gt;The traditional alternative is a hydrometer, a weighted glass float. The deeper it sinks, the less dense the liquid. Sugar makes liquid denser, so falling specific gravity means sugar is being consumed.&lt;/p&gt;

&lt;p&gt;Except the hydrometer doesn't measure sugar either. It measures density, and alcohol is less dense than water, so as fermentation proceeds the alcohol pulls the reading down on its own. A finished dry wine routinely reads below 1.000, lower than plain water, which would be impossible if the hydrometer were really a sugar meter.&lt;/p&gt;

&lt;p&gt;So the refractometer measures how light bends and the hydrometer measures how heavy the liquid is. Neither measures what I actually care about: how much sugar is left, whether the yeast is still working, whether this wine is done. I infer those. The instruments give me evidence.&lt;/p&gt;

&lt;p&gt;Even a steady reading is ambiguous. The traditional sign that fermentation has finished is the same reading several days running. But a fermentation that has stalled also produces the same reading several days running. Mead is notorious for this: honey has very little natural buffering, the pH drifts down as fermentation proceeds, and the yeast can slow or stop well short of dry. A flat line can mean &lt;em&gt;done&lt;/em&gt; or &lt;em&gt;stuck&lt;/em&gt;, and the instrument can't tell you which. You tell them apart with other evidence: where the line flattened, what the pH is doing, what you expected to see.&lt;/p&gt;

&lt;p&gt;That distinction between evidence and state sounds pedantic until you notice it's one we get wrong in software constantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your dashboard has the same problem
&lt;/h2&gt;

&lt;p&gt;CPU utilization isn't health. A service at 15% CPU can be deadlocked, and one at 90% can be doing exactly what you want. An HTTP 200 isn't correctness; it tells you the server returned a response, not that the response was right. A green health check tells you the health check endpoint answered. Model confidence isn't truth; it's a number the model produced about its own output.&lt;/p&gt;

&lt;p&gt;Each of these started as a reasonable proxy under a particular set of conditions. Then the system changed, a new failure mode appeared, or someone started optimizing the number itself, and the proxy drifted away from the state it was meant to represent. The dashboard kept rendering it with exactly the same authority.&lt;/p&gt;

&lt;p&gt;That's the refractometer problem. The metric was calibrated for one state of the system and is now being read in another, and nothing on the display tells you so. And like the flat fermentation line, a steady metric can mean two opposite things: a quiet error rate might mean a healthy service, or one that stopped receiving traffic an hour ago.&lt;/p&gt;

&lt;p&gt;The failure isn't collecting the metric. It's treating the metric as the state instead of as evidence about the state.&lt;/p&gt;

&lt;h2&gt;
  
  
  A reading needs its history
&lt;/h2&gt;

&lt;p&gt;Back to that correction formula. To interpret today's reading, you need the reading from before fermentation began. Without it, the current number is close to uninterpretable. The same Brix value could describe a fermentation that has barely started or one that's nearly finished, depending entirely on where it began.&lt;/p&gt;

&lt;p&gt;The meaning of a measurement depends on its lineage.&lt;/p&gt;

&lt;p&gt;This is where I think observability practice most often falls short. We store the value and the timestamp and call it a record. What we usually drop is everything needed to interpret it later: which instrument produced it, under what conditions, what calibration assumptions it carried, what baseline it should be read against, and what state the system was believed to be in at the time.&lt;/p&gt;

&lt;p&gt;My fermentation log keeps the raw reading and the corrected value side by side. The corrected value is what I act on. The raw value is what I re-derive from if I later learn the correction was off, find a better formula, or discover I misread the original. A corrected value on its own is a conclusion with its evidence thrown away.&lt;/p&gt;

&lt;p&gt;That's a pattern worth stealing. Store the observation separately from the interpretation. Keep the provenance that makes the observation meaningful. Let state be something you derive, with its evidence attached, rather than something you overwrite. It's the same idea behind the forensic receipt work I've been doing: a claim should carry what it was based on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watching isn't free
&lt;/h2&gt;

&lt;p&gt;One more thing the one-gallon batches have taught me. I use a refractometer because a hydrometer needs a much larger sample, and in a small carboy that sample is a real fraction of the batch, and pulling it lets oxygen in. The act of measuring changes the system being measured.&lt;/p&gt;

&lt;p&gt;It also means deciding how often to look. The blackberry had to be punched down twice a day anyway, so twice-daily readings were free. The pear and the mead don't need that kind of handling, so I've settled on a reading every couple of days: often enough to catch a stall, rare enough to leave the batch alone. That's a measurement policy, and it means my log has deliberate gaps in it. Those gaps are their own story, and I'll come back to them.&lt;/p&gt;

&lt;p&gt;Software has the same trade-off: tracing overhead, probes that add latency, sampling that shifts timing. Usually the effect is small. Sometimes it isn't, and how often to look is a design decision with costs on both sides.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evidence, then state
&lt;/h2&gt;

&lt;p&gt;Here's what I've taken from a few weeks of squinting at a prism.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separate what you observed from what you concluded.&lt;/strong&gt; "The refractometer read 9 Brix" and "fermentation is about two-thirds done" are different kinds of statement. They belong in different places, and they should be allowed to disagree.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep the raw reading.&lt;/strong&gt; Corrections, normalizations, and aggregations are interpretations. If you keep only the interpreted value, you can never revisit the interpretation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Record the conditions that make a reading meaningful.&lt;/strong&gt; Instrument, baseline, calibration assumptions, the believed state of the system. A metric without its context is a number waiting to be misread.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat state as a derived claim.&lt;/strong&gt; "Fermentation is complete" isn't a reading. It's a conclusion drawn from several readings over time, plus whatever evidence rules out "stuck," and it should be traceable back to all of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Notice when your proxy stops being a proxy.&lt;/strong&gt; Every metric was calibrated for some range of system behavior. When the system leaves that range, the metric keeps reporting with exactly the same confidence.&lt;/p&gt;

&lt;p&gt;The agent in July was right about one thing: it shouldn't matter whether a number comes from a clipboard or a five-thousand-dollar probe. Where it was wrong was in believing the number could travel without its history.&lt;/p&gt;

&lt;p&gt;The pear wine is still in primary as I write this. The raw refractometer reading says it has a long way to go. The corrected number says otherwise, and the corrected number is only trustworthy because I wrote down what the juice read before the yeast went in.&lt;/p&gt;

&lt;p&gt;The number on the instrument isn't the system. It never was.&lt;/p&gt;

</description>
      <category>observability</category>
      <category>programming</category>
      <category>ai</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
