<?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: Shahbaz Ali</title>
    <description>The latest articles on DEV Community by Shahbaz Ali (@shahbaz_ali).</description>
    <link>https://dev.to/shahbaz_ali</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%2F3944880%2F60ab05fd-3885-4006-83b8-d81b5bcf061e.png</url>
      <title>DEV Community: Shahbaz Ali</title>
      <link>https://dev.to/shahbaz_ali</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shahbaz_ali"/>
    <language>en</language>
    <item>
      <title>One Predicate, Two Meanings, Three Bugs: Building a Contradiction Resolver with the Sanity App SDK</title>
      <dc:creator>Shahbaz Ali</dc:creator>
      <pubDate>Mon, 21 Sep 2026 02:21:18 +0000</pubDate>
      <link>https://dev.to/shahbaz_ali/one-predicate-two-meanings-three-bugs-building-a-contradiction-resolver-with-the-sanity-app-sdk-3jm7</link>
      <guid>https://dev.to/shahbaz_ali/one-predicate-two-meanings-three-bugs-building-a-contradiction-resolver-with-the-sanity-app-sdk-3jm7</guid>
      <description>&lt;p&gt;&lt;em&gt;by shahbaz_ali. Built for the DEV × Sanity Challenge. #sanitychallenge&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When two of your company's documents disagree, somebody has to decide which one wins. Usually that's a Slack thread, and usually it's forgotten by the time the same question comes around again.&lt;/p&gt;

&lt;p&gt;I built a tool that turns that decision into a typed, queryable document with a source, a reason, a precedent chain and an audit trail. An agent proposes a ruling. A human approves it or overrides it. Then the answer changes everywhere at once.&lt;/p&gt;

&lt;p&gt;Everything a judge needs is right here, so I'll put it up top.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live app:&lt;/strong&gt; &lt;a href="https://www.sanity.io/@ouwae45xd/application/szyxap501o5s89a8yhoq5l8c" rel="noopener noreferrer"&gt;https://www.sanity.io/@ouwae45xd/application/szyxap501o5s89a8yhoq5l8c&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Judge login:&lt;/strong&gt; &lt;code&gt;sanity.judges@tokmail.net&lt;/code&gt; / &lt;code&gt;Shahbaz123.&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sanity project ID:&lt;/strong&gt; &lt;code&gt;cqb58l0f&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public dataset (GROQ queryable):&lt;/strong&gt; &lt;a href="https://cqb58l0f.api.sanity.io/v2025-01-01/data/query/production?query=*%5B_type==%22instruction%22%5D%7B_id,resolution,%22topic%22:appliesToTopic-%3Ename%7D" rel="noopener noreferrer"&gt;https://cqb58l0f.api.sanity.io/v2025-01-01/data/query/production?query=*[_type=="instruction"]{_id,resolution,"topic":appliesToTopic-&amp;gt;name}&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The App SDK runs inside the authenticated Sanity Dashboard, which means the app wants a login. The account above is a dedicated Editor-role one I set up for judges, so nobody has to wait on an invite.&lt;/p&gt;

&lt;p&gt;The Triage tab showing three conflicts across three topics&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%2F6j8d87a38jvomtjxfsao.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%2F6j8d87a38jvomtjxfsao.png" alt="The Triage tab showing three conflicts across three topics" width="800" height="505"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/3FO7lC7xahg" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;If the recording isn't your thing, the steps are written out further down and take about two minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I wanted this to exist
&lt;/h2&gt;

&lt;p&gt;Company knowledge lives in documents written by different teams. Legal writes the terms of service, support writes help articles, marketing writes the pricing page, and policy writes the internal rules. Sooner or later two of them disagree.&lt;/p&gt;

&lt;p&gt;Say the internal policy promises refunds within 30 days. The public FAQ says 14. Marketing, for reasons nobody remembers, says 45. Which one is right?&lt;/p&gt;

&lt;p&gt;At most companies the answer comes out of a Slack thread. Someone asks someone else, somebody says "I think 30 is right," and everyone moves on. Three months later a new hire asks the same thing. The thread is buried under 10,000 other messages, the FAQ still says 14 days, and nobody can tell you why the call was made or who made it.&lt;/p&gt;

&lt;p&gt;Contradiction Triage is my attempt at fixing that. Every decision becomes a first-class document with provenance, reasoning, and the ability to be cited by later decisions. Anyone who asks gets the current answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop
&lt;/h2&gt;

&lt;p&gt;A contradiction gets detected and a "case" document is created. An agent proposes a ruling, with a rationale and cited precedents. A human approves it or overrides it. Once the human rules, that decision is the canonical answer the app serves.&lt;/p&gt;

&lt;p&gt;There are three tabs. &lt;strong&gt;Triage&lt;/strong&gt; is the work queue, grouped by stage: "Awaiting your ruling," "Needs a proposal," "Recent rulings" and "Superseded." &lt;strong&gt;History&lt;/strong&gt; lists every ruling ever made, with attribution. &lt;strong&gt;Answers&lt;/strong&gt; shows the canonical answer for each topic and where it came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  The schema is the feature
&lt;/h2&gt;

&lt;p&gt;There are six document types, and I spent more time on them than on any UI code.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;source&lt;/code&gt; is a document of record: title, sourceType, url, content, lastReviewedAt. A &lt;code&gt;topic&lt;/code&gt; is the subject a claim is about. A &lt;code&gt;claim&lt;/code&gt; is a statement with a normalized value like "30 days," a source reference, a topic reference and a confidence score. A &lt;code&gt;case&lt;/code&gt; is a detected contradiction, holding a topic, a snapshot of the conflicting claims and an optional agent proposal. A &lt;code&gt;caseEvent&lt;/code&gt; is an append-only log entry for every stage transition, with the actor who made it (human, agent, system or seed) and a payload snapshot.&lt;/p&gt;

&lt;p&gt;And then there's &lt;code&gt;instruction&lt;/code&gt;, which is the ruling itself. It has a &lt;code&gt;winner&lt;/code&gt; (a claim reference) or an &lt;code&gt;outcomeValue&lt;/code&gt; (a string, for the times a ruling lands on a value no claim asserted). It also carries &lt;code&gt;overruled[]&lt;/code&gt;, typed &lt;code&gt;precedents[]&lt;/code&gt;, and &lt;code&gt;supersedes&lt;/code&gt;, a weak reference to whichever instruction it replaces.&lt;/p&gt;

&lt;p&gt;Four decisions in there do most of the work.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;topic&lt;/code&gt; is a document, not a string
&lt;/h3&gt;

&lt;p&gt;Both &lt;code&gt;claim.topic&lt;/code&gt; and &lt;code&gt;instruction.appliesToTopic&lt;/code&gt; point at the same topic document. So a ruling can't quietly fail to apply because two strings drifted apart.&lt;/p&gt;

&lt;p&gt;Without that, &lt;code&gt;"refund-window"&lt;/code&gt;, &lt;code&gt;"refund window"&lt;/code&gt; and &lt;code&gt;"Refund-Window"&lt;/code&gt; would be three different topics. A ruling on one wouldn't answer the other, and nothing would warn you. The app would just show topics that look related and aren't.&lt;/p&gt;

&lt;h3&gt;
  
  
  Values are comparison keys
&lt;/h3&gt;

&lt;p&gt;Each claim carries a normalized &lt;code&gt;value&lt;/code&gt;, like &lt;code&gt;"30 days"&lt;/code&gt;, &lt;code&gt;"5 USD"&lt;/code&gt; or &lt;code&gt;"12 months"&lt;/code&gt;. Two claims on the same topic with different values contradict each other. Two claims with the same value agree, and the app leaves them alone.&lt;/p&gt;

&lt;p&gt;It's the difference between counting and comparing. If all I had was "how many unresolved claims sit on this topic," two sources saying the same thing would look exactly like a conflict.&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%2Fbxp1xmub4yj8cd18bnhw.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%2Fbxp1xmub4yj8cd18bnhw.png" alt="The Answers tab showing the canonical answer for Account Deletion" width="800" height="505"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Precedents are typed
&lt;/h3&gt;

&lt;p&gt;When a ruling cites an earlier ruling, the citation records &lt;em&gt;how&lt;/em&gt; it was used.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;follows&lt;/code&gt; means the earlier ruling applies here too.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;distinguishes&lt;/code&gt; means the earlier ruling looks applicable but isn't, and here's why.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;overrules&lt;/code&gt; means the earlier ruling was wrong and we're reversing it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A flat reference array can't say any of that, and the difference isn't academic. The shipped dataset has a case built specifically to show &lt;code&gt;distinguishes&lt;/code&gt;. The agent cited an earlier ruling as &lt;code&gt;follows&lt;/code&gt;. The recorded human ruling cited the same one as &lt;code&gt;distinguishes&lt;/code&gt;. &lt;strong&gt;Both landed on the same answer.&lt;/strong&gt; The conclusion survived and the argument didn't, and you can only see that because the precedents are typed.&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%2Fu796bncpzptmms7zh8j6.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%2Fu796bncpzptmms7zh8j6.png" alt="An agent proposal for Warranty Period showing rationale and confidence" width="800" height="505"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Supersession is recorded, not inferred
&lt;/h3&gt;

&lt;p&gt;When a new ruling replaces an old one, the new instruction carries a &lt;code&gt;supersedes&lt;/code&gt; reference. The old ruling never gets deleted. "Is this instruction superseded?" is answered by &lt;code&gt;count(*[_type == "instruction" &amp;amp;&amp;amp; supersedes._ref == ^._id]) &amp;gt; 0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The gap between that and a naive &lt;code&gt;references()&lt;/code&gt; check cost me a real bug, which I'll get to below.&lt;/p&gt;

&lt;h3&gt;
  
  
  The state machine is data
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;workflow.def.json&lt;/code&gt; sits at the repo root and declares four stages (&lt;code&gt;detected → proposed → ruled → superseded&lt;/code&gt;) plus the transitions between them, including which kinds of actor may fire each edge. The seed script validates every event it writes against that table. The UI reads the same table to decide which controls to enable.&lt;/p&gt;

&lt;p&gt;One invariant is enforced structurally. &lt;code&gt;ruled&lt;/code&gt; can only be fired by a &lt;code&gt;human&lt;/code&gt;. No agent, script or system path can commit a decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the App SDK and not a Studio plugin
&lt;/h2&gt;

&lt;p&gt;The challenge offers bonus points for reaching past the Studio, and I'd be lying if I said that had no pull. But the real reason was the write path. What's interesting here isn't the display, it's what happens when someone clicks Approve.&lt;/p&gt;

&lt;p&gt;A Studio plugin renders a form. As far as I can tell, an App SDK app can drive a whole state transition atomically, with optimistic updates, through the same SDK the Studio itself runs on.&lt;/p&gt;

&lt;p&gt;Every ruling is one transaction. Approve, override-with-a-different-claim and override-with-a-new-value all funnel through a single &lt;code&gt;writeRuling()&lt;/code&gt; function, which emits the instruction, the event and the legacy claim sync as one batch. If any part fails, nothing lands. The state can't end up half-updated.&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;await&lt;/span&gt; &lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nf"&gt;createDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;instructionHandle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;instruction&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;publishDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;instructionHandle&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;editDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;winnerHandle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resolved&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}}),&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;overruledHandles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;editDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{...})),&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;overruledHandles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;publishDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="nf"&gt;createDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventHandle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;caseEvent&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;publishDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventHandle&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;h2&gt;
  
  
  Two proposers, one output shape
&lt;/h2&gt;

&lt;p&gt;The agent comes in two flavors, and both write the same &lt;code&gt;case.proposal&lt;/code&gt; fields: outcome, rationale, confidence, precedents, proposedAt, model and promptVersion. The only tell is the &lt;code&gt;model&lt;/code&gt; field.&lt;/p&gt;

&lt;p&gt;The first is deterministic, in &lt;code&gt;scripts/lib/propose.mjs&lt;/code&gt;. It's a pure function that ranks claims by source authority (official beats internal beats external beats community), then by the source's review date, then by claim confidence, then lexicographically by claim id. It runs in the browser behind the "Propose ruling" button and needs no API key. Its confidence comes from whichever rule decided the case: 0.9 for authority, 0.75 for recency, 0.6 when claim confidence decides, and 0.5 for a coin-flip tiebreak.&lt;/p&gt;

&lt;p&gt;The second is &lt;code&gt;scripts/agent.mjs&lt;/code&gt;, which calls Groq's free tier with &lt;code&gt;openai/gpt-oss-120b&lt;/code&gt;. It reads the case's conflicting claims plus prior rulings on the same topic, and writes a proposal with a rationale and cited precedents. It runs from the CLI because that's where the API key lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the pipeline
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;scripts/extract.mjs&lt;/code&gt; reads a source's prose, calls an LLM, and produces structured claims shaped like &lt;code&gt;{statement, value, topicSlug, confidence}&lt;/code&gt;. It does a dry run by default and only writes with &lt;code&gt;--commit&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;scripts/detect.mjs&lt;/code&gt; scans unresolved claims, groups them by topic, and opens a case for any topic with at least two claims and at least two distinct values. No LLM involved, just a group-by.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;scripts/seed.mjs&lt;/code&gt; writes 90 documents: 5 sources, 11 topics, 24 claims, 12 cases, 7 instructions and 31 caseEvents. It does that in five phases, and the reason is the most annoying thing I ran into on the Sanity side.&lt;/p&gt;

&lt;p&gt;Content Lake validates references at write time, in both directions. You can't create a claim that points at an instruction that doesn't exist yet. You can't delete a document another document points at. And when two document types reference each other (claim to instruction, instruction back to claim), no creation order works in a single pass.&lt;/p&gt;

&lt;p&gt;The fix is ugly but it works. Create everything without the cyclic fields, then patch them in. The fifth phase exists only to close the cycle the first four opened. I'm not sure it's the cleanest way to do it, but it's been stable, so I've stopped poking at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I built it
&lt;/h2&gt;

&lt;p&gt;The whole project went through Cline with DeepSeek V4 Flash. One phase at a time, and each phase had to pass &lt;code&gt;npx tsc --noEmit --incremental false&lt;/code&gt; and &lt;code&gt;npx sanity schemas validate&lt;/code&gt; before I moved on. That comes to roughly 40 prompts across 15 phases over a couple of days.&lt;/p&gt;

&lt;p&gt;Here's the finding I'd hand to anyone starting something similar. &lt;code&gt;sanity build&lt;/code&gt; does not type-check. It bundles and strips types. The first time I hit real type errors after a refactor, &lt;code&gt;tsc&lt;/code&gt; exited 2 while &lt;code&gt;sanity build&lt;/code&gt; cheerfully reported success. If you only run the build, you ship broken code with a green checkmark. Run both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three bugs
&lt;/h2&gt;

&lt;p&gt;Three bugs hit during development and they all had the same root cause. Naming them as a class is probably the most useful thing I can write down here.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;claim.resolvedBy&lt;/code&gt; was overloaded
&lt;/h3&gt;

&lt;p&gt;An early version had one field on claims, &lt;code&gt;resolvedBy&lt;/code&gt;, a reference to the instruction that settled it. It looked correct.&lt;/p&gt;

&lt;p&gt;It wasn't. The field really encoded "overruled by," not "involved in a ruling." A winning claim carried no &lt;code&gt;resolvedBy&lt;/code&gt; at all. But GROQ's &lt;code&gt;references()&lt;/code&gt; matches winners and overruled claims alike, so any query deriving "is this claim resolved?" from &lt;code&gt;references()&lt;/code&gt; would mislabel winners as losers.&lt;/p&gt;

&lt;p&gt;The fix was to split the meaning in two. &lt;code&gt;instruction.winner&lt;/code&gt; is singular and &lt;code&gt;instruction.overruled[]&lt;/code&gt; is plural. After that, every query had to be role-aware, and every read of "which instruction settled this claim" had to say how.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;references()&lt;/code&gt; was overloaded too
&lt;/h3&gt;

&lt;p&gt;Later in the build, the canonical-answer query tried to find "the instruction nothing supersedes."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*[_type == "instruction"
  &amp;amp;&amp;amp; appliesToTopic._ref == $topicId
  &amp;amp;&amp;amp; count(*[_type == "instruction" &amp;amp;&amp;amp; references(^._id)]) == 0
] | order(decidedAt desc)[0]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It returned 5 rows when it should have returned 6. One topic's canonical answer had silently vanished.&lt;/p&gt;

&lt;p&gt;The problem is that &lt;code&gt;references()&lt;/code&gt; matches any inbound reference. A later instruction that cited an earlier one as a precedent (&lt;code&gt;precedents[].instruction&lt;/code&gt;) looked, to this query, identical to a later instruction that replaced it. The query had merged "cited by" with "replaced by."&lt;/p&gt;

&lt;p&gt;The fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;count(*[_type == "instruction" &amp;amp;&amp;amp; supersedes._ref == ^._id]) == 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same class of mistake, same shape. One predicate, two meanings.&lt;/p&gt;

&lt;h3&gt;
  
  
  A comment swallowed an export
&lt;/h3&gt;

&lt;p&gt;The third one lived in code instead of data. I was extending a docblock in &lt;code&gt;src/queries.ts&lt;/code&gt; and dropped the closing &lt;code&gt;*/&lt;/code&gt;. The &lt;code&gt;export const CANONICAL_ANSWER_QUERY&lt;/code&gt; line got eaten by the comment.&lt;/p&gt;

&lt;p&gt;That is valid JavaScript. &lt;code&gt;sanity build&lt;/code&gt; reported success, the app deployed, the app rendered. The only problem was that &lt;code&gt;canonicalAnswerOptions()&lt;/code&gt; would have thrown a &lt;code&gt;ReferenceError&lt;/code&gt; the moment anybody opened the Answers tab.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tsc&lt;/code&gt; caught it. Nothing else did.&lt;/p&gt;

&lt;h3&gt;
  
  
  What they have in common
&lt;/h3&gt;

&lt;p&gt;Every one of these was something doing two jobs, quietly. A field meaning two things. A predicate matching two patterns. A comment swallowing code. The build caught none of them. &lt;code&gt;tsc&lt;/code&gt;, a live GROQ query and a click-through caught all three.&lt;/p&gt;

&lt;p&gt;In typed content models, I don't think the usual failure is "the type is wrong." It's that a mechanism is overloaded. Each time I split an overloaded thing, a whole class of bugs went away. Each time I tolerated an overload because it was convenient, a new bug showed up within a week.&lt;/p&gt;

&lt;h2&gt;
  
  
  The eval, and where the agent is wrong
&lt;/h2&gt;

&lt;p&gt;The deterministic proposer is a total function, so it always makes a call. I scored it against the rulings on the 7 cases that have both a proposal and a ruling. (The dataset has 12 cases overall.) The report below is trimmed from what &lt;code&gt;scripts/eval.mjs&lt;/code&gt; prints (my run was generated 2026-09-20T00:56:59Z):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OVERALL ACCURACY
  Outcome match: 6 / 7 (86%)
  Overrides:     1 / 7 (14%)

BY CONFIDENCE BUCKET
  0.9-1.0:  5 / 5  (100%) - high-confidence
  0.7-0.9:  1 / 1  (100%)
  0.5-0.7:  0 / 0  (n/a)
  0.0-0.5:  0 / 1  (0%) - coin flips

BY CATEGORY
  authority-beats-recency:   3 / 3  (100%) - 0 overridden
  newer-supersedes-older:    1 / 1  (100%) - 0 overridden
  precedent-applies:         1 / 1  (100%) - 0 overridden
  precedent-misleads:        1 / 1  (100%) - 0 overridden
  true-tie:                  0 / 1  (0%)   - 1 overridden

CROSS-CHECK
  Derived override agrees with recorded payload.approvedProposal: 7 / 7 (100%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fxj3rmx9rae9cqqctcp0a.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%2Fxj3rmx9rae9cqqctcp0a.png" alt="Terminal image" width="800" height="505"&gt;&lt;/a&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%2F3jgt8gmyd7r8di4l2pvq.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%2F3jgt8gmyd7r8di4l2pvq.png" alt="Terminal image" width="800" height="505"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The headline number is the least interesting part. I care more about the shape.&lt;/p&gt;

&lt;p&gt;The confidence buckets behave the way you'd hope. Every case at 0.7 or above matched the human, and the only miss sat in the lowest bucket, where the proposer is effectively saying "I'm flipping a coin." The confidence values come straight from which rule fired.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;precedent-misleads&lt;/code&gt; case is my favorite row. On &lt;code&gt;case-gift-card-expiry-1&lt;/code&gt; the agent cited the Account Deletion ruling as &lt;code&gt;follows&lt;/code&gt;. The recorded human ruling cited it as &lt;code&gt;distinguishes&lt;/code&gt;. Both landed on 12 months, so the outcome matched and it scores as a hit. But the argument was different, and the category is about the citation, not the outcome. An untyped reference array would have shown two rulings pointing at the same earlier ruling and called it agreement. That's the case that convinced me the schema was worth building.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;true-tie&lt;/code&gt; miss is a designed one. Two claims share source, review date and confidence, so nothing separates them. The proposer reports 0.5 and calls it a coin flip. The human ruling on Cancellation Notice established 45 days, a value neither claim asserted. So the agent proposed a claim and the human proposed a value, and the report itself flags this as a miss by construction: &lt;code&gt;case.proposal&lt;/code&gt; has no field for a value. Counting that as a miss is honest, but the better reading is that the agent was right inside its scope, and this is exactly where its scope ends.&lt;/p&gt;

&lt;p&gt;Then the cross-check, which is the line that lets me trust everything else. The override column is derived by comparing outcomes. The event log records the same fact independently, as &lt;code&gt;payload.approvedProposal&lt;/code&gt;. They agree on 7 of 7. If they'd diverged, I'd have thrown out the numbers above.&lt;/p&gt;

&lt;p&gt;Now the caveat, and it's a big one. This is 7 cases, and the rulings are seed fixtures I wrote myself, which the app labels "Seed fixture (simulated human)." So the ground truth is me playing reviewer, with one labeler. Five of the 12 cases couldn't be scored at all because they lack a proposal or a ruling. Read the categories as directions, not rates. The harness isn't there to prove the agent is right. It's there so the ways it's wrong are easy to see.&lt;/p&gt;

&lt;h2&gt;
  
  
  What isn't built
&lt;/h2&gt;

&lt;p&gt;I'd rather say this plainly than have a judge find it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Extraction and detection run from the CLI, not the UI.&lt;/strong&gt; They need an API key and an outbound call to Groq, and a browser can't hold either (there's no safe place for the key in the bundle, and CORS blocks direct Groq calls anyway). Detection also writes to the dataset with a write token. The production wiring would be a Sanity Function triggered by a webhook when a source is created. The scripts read and write the same schema the UI does, so that's a deployment change and not an architectural one. To make the pipeline drivable from the app anyway, the UI ships with pre-computed extraction samples.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Value canonicalization is missing.&lt;/strong&gt; &lt;code&gt;12 months&lt;/code&gt; and &lt;code&gt;1 year&lt;/code&gt; are the same assertion, and today the detector treats them as different. The right fix is probably a normalized &lt;code&gt;{amount, unit}&lt;/code&gt; shape, or a canonicalization step at write time. This is the extractor's weakest point and I know it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Detection only sees unresolved claims.&lt;/strong&gt; A fresh source that contradicts an already-ruled answer is invisible right now. That's arguably the most interesting product case, and closing it means comparing new claims against the current canonical answer. I'd call that a design decision, not a bug fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-tenancy isn't there.&lt;/strong&gt; Topics are global. A multi-company deployment would add a company reference to &lt;code&gt;topic&lt;/code&gt; and push it through every query. The governance loop itself wouldn't change, it would just gain a scope filter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workflows isn't integrated.&lt;/strong&gt; I spiked Sanity's Workflows prerelease before building anything else, and it hit a peer-dependency wall. &lt;code&gt;@sanity/workflow-sdk@0.33.0&lt;/code&gt; requires &lt;code&gt;@sanity/sdk-react@^3.1.0&lt;/code&gt;, and my working app is on 2.20.2. Bumping the SDK by a major version 10 days before the deadline would have put a working app at risk for a bonus criterion, so I killed it. I designed the scripts and schema so that swapping the hand-rolled state machine for the Workflows engine should be a data migration and not a rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the whole pipeline
&lt;/h2&gt;

&lt;p&gt;Open &lt;a href="https://www.sanity.io/@ouwae45xd/application/szyxap501o5s89a8yhoq5l8c" rel="noopener noreferrer"&gt;https://www.sanity.io/@ouwae45xd/application/szyxap501o5s89a8yhoq5l8c&lt;/a&gt; and sign in with &lt;code&gt;sanity.judges@tokmail.net&lt;/code&gt; / &lt;code&gt;Shahbaz123&lt;/code&gt;. It's a shared workspace, so whatever you click is visible to the next visitor.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;Triage&lt;/strong&gt;, then &lt;strong&gt;Warranty Period&lt;/strong&gt;. Read the agent's proposal, its confidence and its rationale.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Approve&lt;/strong&gt;. Watch the case move to "Recent rulings," History gain an entry, and the Answers tab update the canonical answer for Warranty Period.&lt;/li&gt;
&lt;/ol&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%2F0gjec05p9e34qckbl1x5.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%2F0gjec05p9e34qckbl1x5.png" alt="A detected case ready for the Propose ruling button" width="800" height="505"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If you want the full pipeline, click &lt;strong&gt;+ Add Source&lt;/strong&gt;, load the "Refund Policy v2" sample, and click &lt;strong&gt;Create Source + Claims&lt;/strong&gt;. The app writes the source, extracts its claims (pre-computed, since extraction needs a server-side key), detects a fresh contradiction, and opens &lt;code&gt;case-refund-window-2&lt;/code&gt;, a 3-claim conflict between 14, 30 and 45 days.&lt;/li&gt;
&lt;li&gt;Click the new case, hit &lt;strong&gt;Propose ruling&lt;/strong&gt;, and watch the deterministic proposer rank the claims right there in the browser.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/ShahbazVK/SanityChallenge" rel="noopener noreferrer"&gt;https://github.com/ShahbazVK/SanityChallenge&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The public dataset is queryable right now, so every schema claim in this post can be verified against it with GROQ. The queries above are the ones the app itself runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the whole thing is really about
&lt;/h2&gt;

&lt;p&gt;I've spent a lot of words on schemas and bugs, so here's the part I care about most.&lt;/p&gt;

&lt;p&gt;A decision is content. It isn't a comment on a document or a Slack message or a line in a changelog. It's a document with a source, a reason, a precedent chain and an audit trail. It can be cited by a later decision. It can be superseded without being deleted. It can be served, untouched, to any system that needs a canonical answer.&lt;/p&gt;

&lt;p&gt;The agents and the workflow engine are interesting, and I had fun with both. But the thing this project actually shows is simpler. If you model the decision properly, the decision becomes queryable. Once it's queryable, it's usable, by a person or by another agent, and nobody has to go digging through a Slack archive.&lt;/p&gt;

&lt;p&gt;That's the bet. The rest is implementation.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built for the DEV × Sanity Challenge. #sanitychallenge. Source was vibe-coded with Cline and DeepSeek V4 Flash.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sanitychallenge</category>
      <category>sanity</category>
      <category>devchallenge</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
