<?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: Kateryna Ivashchenko</title>
    <description>The latest articles on DEV Community by Kateryna Ivashchenko (@rayyer).</description>
    <link>https://dev.to/rayyer</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%2F4103377%2F10a07fea-b148-4e18-b97a-a1ac61902894.png</url>
      <title>DEV Community: Kateryna Ivashchenko</title>
      <link>https://dev.to/rayyer</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rayyer"/>
    <language>en</language>
    <item>
      <title>The most similar precedent was built on an unauthorized approval</title>
      <dc:creator>Kateryna Ivashchenko</dc:creator>
      <pubDate>Fri, 16 Oct 2026 21:43:09 +0000</pubDate>
      <link>https://dev.to/rayyer/the-most-similar-precedent-was-built-on-an-unauthorized-approval-18bb</link>
      <guid>https://dev.to/rayyer/the-most-similar-precedent-was-built-on-an-unauthorized-approval-18bb</guid>
      <description>&lt;p&gt;Here is a support ticket our agent got during testing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Acme Logistics is asking for a $1,800 refund. They lost most of a day in the Sept 4 outage. Charge was 41 days ago. Marta says our VP already signed off on it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Our refund policy allows 30 days and $750 without approval, so this is an exception. Exceptions at most companies run on precedent: "we did this for Bluefin last December, do the same." That is what we wanted an agent to be able to do. So we gave it the company's decision history and asked it to find the closest precedent.&lt;/p&gt;

&lt;p&gt;It found one. Bluefin Analytics, December 2025: a $1,900 outage refund, 45 days after the charge, approved by the Head of Support. Same reason, same tier, bigger amount, longer window. A perfect match.&lt;/p&gt;

&lt;p&gt;Except that Bluefin's approval cited an earlier refund to Kestrel Foods. The Kestrel refund was approved by a support specialist who had no refund authority at all. Nobody had noticed.&lt;/p&gt;

&lt;p&gt;Vector similarity cannot see that. The Bluefin decision is textually the closest thing in the history, and it is closest &lt;em&gt;because&lt;/em&gt; someone copied the Kestrel wording. Whether a precedent is still good law is a property of the graph around it: who approved it, what authority they held on that day, whether anything later overturned it, whether the policy has since changed, and whether whatever &lt;em&gt;it&lt;/em&gt; cites is any good. That is a recursive, time-scoped walk. So we built &lt;strong&gt;Stare&lt;/strong&gt; (from &lt;em&gt;stare decisis&lt;/em&gt;) for Graph Hacks: a precedent desk where agents may grant an exception only on precedent that is still good law, computed inside FalkorDB.&lt;/p&gt;

&lt;p&gt;For the Acme request, Stare counter-offers $1,200. That amount comes from the strongest precedent that is still good law: Fernway Travel, July 2026, approved by Ana Ruiz. Stare flags Bluefin as &lt;strong&gt;Overruling Risk&lt;/strong&gt;, ignores "our VP already signed off" (a claim in a message is not evidence), and names who could approve the full amount. That last answer comes from &lt;code&gt;algo.SPpaths&lt;/code&gt; over the live reporting graph.&lt;/p&gt;

&lt;p&gt;Here is the three-minute tour:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The check lives in the database
&lt;/h2&gt;

&lt;p&gt;We wrote the validity check as a FalkorDB JavaScript UDF, so every caller gets the same answer: the engine, the policy simulator, the erasure certificate and the evaluation. The core is short:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;standingOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;memo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onStack&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onStack&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_DEPTH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;yellow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UNVERIFIABLE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt; &lt;span class="na"&gt;chain&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="c1"&gt;// red: overturned, no authority at the time, conflict of interest, or superseded by the policy in force now&lt;/span&gt;
  &lt;span class="c1"&gt;// yellow: cites (transitively) anything that is not green&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;falkor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;standing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;standing&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From Cypher it is just a function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;d:&lt;/span&gt;&lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;d.id&lt;/span&gt; &lt;span class="ow"&gt;IN&lt;/span&gt; &lt;span class="n"&gt;$ids&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;d.id&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stare.covers&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;$facts&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;cover&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stare.standing&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;$now&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;standing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The part we got wrong first was &lt;em&gt;time&lt;/em&gt;. "Did the approver have authority?" has to be asked at the moment of the decision, not today. &lt;code&gt;HOLDS&lt;/code&gt; and &lt;code&gt;MAY_APPROVE&lt;/code&gt; edges carry &lt;code&gt;validFrom&lt;/code&gt;/&lt;code&gt;validTo&lt;/code&gt;, and the UDF checks the interval that contains &lt;code&gt;decision.at&lt;/code&gt;. Victor, our fictional VP, left the company in August. His June approvals are still good law, and an agent may still follow them. His &lt;em&gt;delegation&lt;/em&gt; to the support agent, though, died the day he left. Most people's first instinct is the reverse, which is why one of our evaluation traps is exactly this case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Routing over a graph that changes
&lt;/h2&gt;

&lt;p&gt;When nobody's precedent covers a request, we escalate to the nearest person who holds the authority right now. &lt;code&gt;algo.SPpaths&lt;/code&gt; finds shortest paths, but it has no idea that a reporting line ended last month. Rather than maintain a second "current" edge type, we gave every &lt;code&gt;REPORTS_TO&lt;/code&gt; edge a cost: 0 while it is live, 1 once it ends. Then we asked for paths with &lt;code&gt;maxCost: 0&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;CALL&lt;/span&gt; &lt;span class="n"&gt;algo.SPpaths&lt;/span&gt;&lt;span class="ss"&gt;({&lt;/span&gt;&lt;span class="py"&gt;sourceNode:&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="py"&gt;targetNode:&lt;/span&gt; &lt;span class="n"&gt;cand&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="py"&gt;relTypes:&lt;/span&gt; &lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'REPORTS_TO'&lt;/span&gt;&lt;span class="ss"&gt;],&lt;/span&gt;
                   &lt;span class="py"&gt;weightProp:&lt;/span&gt; &lt;span class="s1"&gt;'w'&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="py"&gt;costProp:&lt;/span&gt; &lt;span class="s1"&gt;'dead'&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;maxCost&lt;/span&gt;&lt;span class="dl"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;pathCount&lt;/span&gt;&lt;span class="dl"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A ledger that cannot fork
&lt;/h2&gt;

&lt;p&gt;Every decision is appended to a hash chain. Computing the hash in the application meant read-hash-write with retries under contention. Instead we put a pure-JS SHA-256 in the same UDF library and computed the new hash &lt;em&gt;inside the write query&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;lh:&lt;/span&gt;&lt;span class="n"&gt;LedgerHead&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;id:&lt;/span&gt;&lt;span class="s1"&gt;'head'&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lh&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stare.sha256&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lh.hash&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s1"&gt;'|'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;$ledgerPayloadHash&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;nh&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;le:&lt;/span&gt;&lt;span class="n"&gt;LedgerEntry&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;seq:&lt;/span&gt; &lt;span class="n"&gt;lh.seq&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="py"&gt;prev:&lt;/span&gt; &lt;span class="n"&gt;lh.hash&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="py"&gt;hash:&lt;/span&gt; &lt;span class="n"&gt;nh&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;...&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;lh.seq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lh.seq&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lh.hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FalkorDB serialises writes per graph, and a single &lt;code&gt;GRAPH.QUERY&lt;/code&gt; is atomic. So 64 concurrent decisions from three agents on 16 connections produce 64 contiguous entries, the shared budget is never overspent, and no case is decided twice. The verifier recomputes the chain with Node's crypto, which is independent of the UDF, and compares every live decision with what the ledger recorded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving it, and being honest about the 100%
&lt;/h2&gt;

&lt;p&gt;We pre-registered an evaluation: 112 requests across 13 kinds of trap. We hashed the answer key and committed the hash before running anything. The key comes from the written rules, checked by a separate TypeScript implementation. That second implementation also has to agree with the UDF on every seeded decision, as a test.&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;Correct&lt;/th&gt;
&lt;th&gt;False approvals&lt;/th&gt;
&lt;th&gt;Refund $ leaked&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stare&lt;/td&gt;
&lt;td&gt;112/112&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vector RAG (top-8 similar decisions + policy + customer directory)&lt;/td&gt;
&lt;td&gt;68/112&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;$14,500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM only (policy + customer directory)&lt;/td&gt;
&lt;td&gt;66/112&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;$17,200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Stare's score is not the interesting part. The engine executes the rules the key encodes, and the only stochastic piece is the model that turns a message into fields. The baselines are the interesting part. On the Acme-shaped trap ("looks right, rests on an unauthorized approval"), vector RAG scored &lt;strong&gt;0 out of 8&lt;/strong&gt;, in both runs we kept. On requests that a newer policy forbids, giving the model similar past decisions made it &lt;em&gt;worse&lt;/em&gt; than giving it nothing: 6–7 out of 12 against 11–12. The old precedents pulled it towards the old rules. (The baselines are stochastic, so the repo keeps both runs; Stare scored 112/112 in every run.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;FalkorDB 4.22.1 crashed under our test suite.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Twice it segfaulted. The first time we were creating indexes and constraints on several graphs at once, from parallel test files.&lt;/li&gt;
&lt;li&gt;The second time we were deleting a few hundred graphs at the start of a run while new ones were being created.&lt;/li&gt;
&lt;li&gt;We now build a schema'd template once, serially, and create every other graph with &lt;code&gt;GRAPH.COPY&lt;/code&gt;. That is also how visitor sandboxes work in production. Each test run starts from a fresh container.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;GRAPH.COPY&lt;/code&gt; forks the server.&lt;/strong&gt; Two concurrent copies fail with &lt;code&gt;could not fork&lt;/code&gt;, so copies are serialised and retried.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Our erasure scanner passed when it shouldn't have.&lt;/strong&gt; &lt;code&gt;node.attributes&lt;/code&gt; inside a UDF is a host object: its keys don't enumerate, so &lt;code&gt;for (k in attrs)&lt;/code&gt; found nothing and every scan came back clean. We caught it only because the erasure certificate is judged against a &lt;em&gt;negative control&lt;/em&gt;: a naive "delete the contact node" on a copy that must fail the same checks. It passed, which was impossible, so the scanner was broken. The fix is &lt;code&gt;JSON.parse(JSON.stringify(n.attributes))&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then the certificate refused a correct erasure.&lt;/strong&gt; Our synthetic contacts had phone numbers drawn from 100 values and there were 101 contacts. After we erased Marta, her number was still in the graph, on someone else's record. The certificate was right to refuse. Phone numbers are unique now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fuzzy matching merged two companies.&lt;/strong&gt; Jaro-Winkler gives "Acme Logistic GmbH" 0.93 against "Acme Logistics", above our 0.92 threshold. That is a different legal entity (a customer of the &lt;em&gt;other&lt;/em&gt; tenant). Now a fuzzy match needs the same number of words and each word to match: typos pass, an extra "GmbH" or "Europe" doesn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read-only ACLs.&lt;/strong&gt; &lt;code&gt;%R~pattern&lt;/code&gt; looks like the obvious read-only key permission. It does not work for graph commands, because their key specs are declared read-write. The read-only auditor (FalkorDB's own MCP server, which we run next to ours) uses &lt;code&gt;~stare:northwind +GRAPH.RO_QUERY&lt;/code&gt; instead. Try a write through it and the database answers &lt;code&gt;NOPERM … 'graph.QUERY'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCP 2026-07-28 and the legacy era.&lt;/strong&gt; People confirm rulings through a multi-round-trip input request (&lt;code&gt;inputRequired.elicit&lt;/code&gt; on the server). Our first client test failed with "this 2025-era connection did not declare the required capability". The v2 client defaults to the legacy handshake unless you set &lt;code&gt;versionNegotiation: { mode: 'auto' }&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'd tell the next team
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Put the decision where the data is.&lt;/strong&gt; Once validity was a database function, every surface agreed by construction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the model's job small.&lt;/strong&gt; Ours parses fields and writes an explanation. A gate rejects any explanation that names a person, company or amount outside the trace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the negative control first.&lt;/strong&gt; It found a bug that every positive test had missed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stare is open source (MIT): &lt;a href="https://github.com/RaYYeR220/stare" rel="noopener noreferrer"&gt;https://github.com/RaYYeR220/stare&lt;/a&gt;. The live desk gives each visitor a private copy of the demo company, where you can overturn a decision, change a policy, erase a customer, or run the 64-way race yourself: &lt;a href="https://stare-desk.vercel.app" rel="noopener noreferrer"&gt;https://stare-desk.vercel.app&lt;/a&gt;&lt;/p&gt;

</description>
      <category>falkordb</category>
      <category>graphdatabase</category>
      <category>agents</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Clockwrit — every hour has a citation (an agent that knows what time it is, legally)</title>
      <dc:creator>Kateryna Ivashchenko</dc:creator>
      <pubDate>Sun, 04 Oct 2026 22:07:23 +0000</pubDate>
      <link>https://dev.to/rayyer/clockwrit-every-hour-has-a-citation-an-agent-that-knows-what-time-it-is-legally-5ee3</link>
      <guid>https://dev.to/rayyer/clockwrit-every-hour-has-a-citation-an-agent-that-knows-what-time-it-is-legally-5ee3</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 One: Ship an Agent That Queries Real Content&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;On &lt;strong&gt;1 November 2026, Canada stops changing its clocks&lt;/strong&gt; — one province at a time. British Columbia, Alberta and the Northwest Territories already went permanent this year; Manitoba follows on 31 October. IANA shipped five tzdata releases in 2026 to keep up, and for several of these changes it &lt;em&gt;deliberately&lt;/em&gt; models the switch on 1 November at 02:00 instead of the legal date (a documented "temporary hack"). Morocco quietly went back to GMT on 20 September.&lt;/p&gt;

&lt;p&gt;Meanwhile, the Node.js you're running ships whatever tz data it was built with. On my machine that's 2025b: &lt;strong&gt;11 of 597 zones are wrong&lt;/strong&gt; in 2026–27. Even the Vercel runtime this project is deployed on (tzdata 2026c) still puts Winnipeg an hour off. Ask a search engine whether Ukraine abolished daylight saving time and you'll get "yes" — the bill passed in 2024 and was never signed.&lt;/p&gt;

&lt;p&gt;So I built an agent that answers &lt;em&gt;what the clock and the calendar legally say&lt;/em&gt; at a place and moment — and on whose authority.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clockwrit&lt;/strong&gt; reads the law, the IANA time zone database and your runtime's own clock, and tells you which one is right — with the decree, the date and the source quote.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ask.&lt;/strong&gt; "A weekly call is set for Mondays 10:00 in New York. What time is it in Winnipeg on 2 November 2026, and why?" The answer comes back as cards, not prose: the local time, and &lt;strong&gt;four clocks side by side — the law, IANA 2026e, this server, and &lt;em&gt;your browser&lt;/em&gt;, read live&lt;/strong&gt; — plus every instrument the answer rests on, with its authority tier (§ primary law, † reference data, ‡ community) and its legal status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pending laws as Content Releases.&lt;/strong&gt; The US Sunshine Protection Act, Ukraine's bill 4201, Florida's conditional permanent-DST law, the EU proposal and three more are modelled as Sanity Content Releases. Pick one and the agent answers &lt;em&gt;as if it had passed&lt;/em&gt; — the same tools, reading the dataset through the release perspective.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A ruling desk.&lt;/strong&gt; When the Knowledge Base finds two sources that disagree, the agent drafts a ruling, a person signs it, and the decision becomes a standing instruction for every future rebuild. (More below — this is the part I'm proudest of.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A schedule auditor&lt;/strong&gt;, &lt;strong&gt;a drift report&lt;/strong&gt;, &lt;strong&gt;an eval page&lt;/strong&gt;, &lt;strong&gt;an MCP server&lt;/strong&gt;, &lt;strong&gt;a CLI&lt;/strong&gt; and &lt;strong&gt;a Sanity Dashboard app&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&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%2Fw8vvi6b4uft6h6wbzo26.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%2Fw8vvi6b4uft6h6wbzo26.png" alt="Ask: a real answer with the four clocks and the legal basis" width="800" height="977"&gt;&lt;/a&gt;&lt;/p&gt;

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

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

&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://clockwrit.vercel.app" rel="noopener noreferrer"&gt;https://clockwrit.vercel.app&lt;/a&gt; — no login needed.&lt;/p&gt;

&lt;p&gt;A five-minute path, if you're reviewing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Home&lt;/strong&gt; — the page reads &lt;em&gt;your&lt;/em&gt; browser's tz data for 2 November and grades it against the law (&lt;a href="https://clockwrit.vercel.app" rel="noopener noreferrer"&gt;landing&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://clockwrit.vercel.app/ask" rel="noopener noreferrer"&gt;Ask&lt;/a&gt;&lt;/strong&gt; — try a suggested question, then switch "Answer under" to &lt;em&gt;Ukraine bill 4201&lt;/em&gt; and ask about Kyiv next July.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://clockwrit.vercel.app/desk" rel="noopener noreferrer"&gt;Ruling desk&lt;/a&gt;&lt;/strong&gt; — draft and approve a ruling. &lt;strong&gt;Reviewer passcode: &lt;code&gt;every-hour-has-a-citation&lt;/code&gt;&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://clockwrit.vercel.app/audit?demo=1" rel="noopener noreferrer"&gt;Audit&lt;/a&gt;&lt;/strong&gt; — a weekly meeting across five cities, every occurrence checked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://clockwrit.vercel.app/eval" rel="noopener noreferrer"&gt;Eval&lt;/a&gt;&lt;/strong&gt; — every question, every answer, every verdict.&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%2Fx3b9qyilz05na1g5k5ih.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%2Fx3b9qyilz05na1g5k5ih.png" alt="Four clocks, one instant" width="800" height="549"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Repository: &lt;a href="https://gitlab.com/RaYYeR220/clockwrit" rel="noopener noreferrer"&gt;https://gitlab.com/RaYYeR220/clockwrit&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Update, 22 Oct: the repository moved from GitHub to GitLab after my GitHub account was suspended. Same code and the same commit history — the submission state is commit &lt;code&gt;f474794&lt;/code&gt; (4 Oct). The only later commit is a one-line change so the site's link to the eval answer key points at GitLab.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A pnpm monorepo: a deterministic legal-time engine (&lt;code&gt;packages/core&lt;/code&gt;, 30 tests), the Next.js app and agent (&lt;code&gt;web&lt;/code&gt;), the Studio and schema (&lt;code&gt;studio&lt;/code&gt;), the App SDK Dashboard app (&lt;code&gt;desk&lt;/code&gt;), the dataset and the 90-source corpus (&lt;code&gt;knowledge&lt;/code&gt;), the evaluation (&lt;code&gt;eval&lt;/code&gt;) and the CLI (&lt;code&gt;packages/cli&lt;/code&gt;). MIT.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Used Sanity
&lt;/h2&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%2Fytdjvf2yawqoul8b3780.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%2Fytdjvf2yawqoul8b3780.png" alt="Architecture" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. A typed dataset where every fact points at its law
&lt;/h3&gt;

&lt;p&gt;The schema is the product. A &lt;code&gt;ruleSegment&lt;/code&gt; is one zone's clock regime over one span of time — the standard offset, the DST rule written the way the law (and zic) writes it (&lt;code&gt;Sun&amp;gt;=8 2:00 wall&lt;/code&gt;), &lt;code&gt;validFrom&lt;/code&gt;/&lt;code&gt;validTo&lt;/code&gt; as UTC instants — and a required &lt;code&gt;basis[]&lt;/code&gt; that references the &lt;strong&gt;instrument&lt;/strong&gt; it rests on. An &lt;code&gt;instrument&lt;/code&gt; is anything that makes a claim: a statute, a decree, a bill, an IANA release, a Microsoft notice, a news article, a holiday library. Each has an &lt;strong&gt;authority tier&lt;/strong&gt; (primary / secondary / community), a &lt;strong&gt;legal status&lt;/strong&gt; (in force, enacted-not-effective, conditional, pending bill, proposed, vetoed…), dates, and verbatim quotes in their original language with translations.&lt;/p&gt;

&lt;p&gt;The calendar side has &lt;code&gt;holiday&lt;/code&gt; (with &lt;code&gt;dateCertainty&lt;/code&gt;: fixed by law, announced, or merely calculated — Eid moves on a moon sighting), &lt;code&gt;weekendRegime&lt;/code&gt; (the UAE moved its weekend in 2022), &lt;code&gt;workdayOverride&lt;/code&gt; (China's adjusted working Saturdays) and &lt;code&gt;holidaySuspension&lt;/code&gt; (Ukraine's martial law suspends holiday days off). Each &lt;code&gt;jurisdiction&lt;/code&gt; records which years have a &lt;em&gt;complete&lt;/em&gt; holiday list, so the agent knows when it's allowed to say "working day".&lt;/p&gt;

&lt;p&gt;845 documents, 179 instruments (83 of them primary law), 47 zones, 58 jurisdictions. Every one of the 114 clock regimes is checked against IANA tzdata 2026e — daily and hourly around every transition, 2020–2028 — before it's loaded.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Content Releases as pending legislation
&lt;/h3&gt;

&lt;p&gt;A release is a set of document versions that publish together. That's exactly what a bill is. Seven releases hold the clock regimes as they would read if each law took effect, and the agent's tools read the dataset with &lt;code&gt;perspective: [releaseId]&lt;/code&gt;. Asking "what if Ukraine's bill is signed?" isn't a prompt trick — it's the same deterministic computation over a different perspective.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Two Sanity Context MCP endpoints
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;clockwrit-catalog&lt;/code&gt;&lt;/strong&gt; — GROQ mode over the dataset. The agent uses &lt;code&gt;groq_query&lt;/code&gt; and &lt;code&gt;schema_explorer&lt;/code&gt; to find instruments, statuses and dates. The endpoint's instructions explain what authority and status mean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;clockwrit-sources&lt;/code&gt;&lt;/strong&gt; — Knowledge Base mode. The agent uses &lt;code&gt;knowledge_base_search&lt;/code&gt; / &lt;code&gt;knowledge_base_read&lt;/code&gt; for what the sources actually say.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both endpoints' &lt;code&gt;/initial-context&lt;/code&gt; is inlined into the system prompt so the agent doesn't spend a tool call on orientation. Every Ask answer shows an &lt;strong&gt;evidence trail&lt;/strong&gt; of the Context tool calls it made. Conversations are saved to Context Insights through &lt;code&gt;@sanity/context&lt;/code&gt;'s AI SDK integration.&lt;/p&gt;

&lt;p&gt;The agent never states an offset or a working day from memory. Three deterministic tools — &lt;code&gt;legal_time&lt;/code&gt;, &lt;code&gt;working_day&lt;/code&gt;, &lt;code&gt;audit_schedule&lt;/code&gt; — read the typed records and compute. &lt;strong&gt;Code decides; the model explains.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  4. A Knowledge Base built from 90 real sources — in two passes
&lt;/h3&gt;

&lt;p&gt;I imported the corpus in two phases, each source with a header naming its authority tier:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Phase A, the law first:&lt;/strong&gt; statutes, proclamations, gazettes, government announcements, IANA NEWS entries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase B, then the internet:&lt;/strong&gt; the news headline that overstated a bill, the Microsoft notice with a different date, the Wikipedia line its own citation doesn't support, the holiday library that's a day off for Pakistan's Eid.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. The ruling loop: Knowledge Base issues → instructions
&lt;/h3&gt;

&lt;p&gt;When the build finds a fact the sources disagree on, it files a &lt;strong&gt;conflict issue&lt;/strong&gt; with both sides and the exact quoted lines. The one that matters most: &lt;em&gt;Manitoba's proclamation says 31 October; IANA 2026e says 1 November 02:00.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On the ruling desk the agent drafts a ruling — which side, why (authority, status, dates), and a cross-check against the typed records — through a forced tool call. &lt;strong&gt;It cannot apply it.&lt;/strong&gt; Applying is a code path behind a gate: the reviewer passcode, or — from the Dashboard app — the reviewer's own Sanity token, accepted only if a dry-run edit of that ruling succeeds with their permissions &lt;em&gt;and&lt;/em&gt; they administer the organisation that owns the Knowledge Base. Approval resolves the issue through the Knowledge Base API, which mints an &lt;strong&gt;instruction&lt;/strong&gt; that every later build follows, and writes the ruling back to the dataset.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. The App SDK desk
&lt;/h3&gt;

&lt;p&gt;A Dashboard app lists rulings live (&lt;code&gt;useDocuments&lt;/code&gt;, &lt;code&gt;useDocumentProjection&lt;/code&gt;), lets an editor approve with their own identity (&lt;code&gt;useAuthToken&lt;/code&gt;), and shows the pending laws (&lt;code&gt;useActiveReleases&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this only works because the content is structured
&lt;/h3&gt;

&lt;p&gt;I wrote 40 questions from the verified research &lt;em&gt;before&lt;/em&gt; the agent existed, each with an answer key and a trap reason. Ten were used while building; the 30 held-out ones were run once against three arms, graded by a model from a different family:&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;Correct&lt;/th&gt;
&lt;th&gt;Traps correct&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Clockwrit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30 / 30&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14 / 14&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same model, no tools&lt;/td&gt;
&lt;td&gt;22 / 30&lt;/td&gt;
&lt;td&gt;8 / 14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keyword search (BM25) over the same 90 sources + same model&lt;/td&gt;
&lt;td&gt;16 / 30&lt;/td&gt;
&lt;td&gt;6 / 14&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The model alone is confidently wrong exactly where the world changed this year: it says Casablanca is UTC+1 (it isn't, since 20 September), that 01:30 on 20 September happened once there (twice), that Pakistan's Eid al-Adha was the 28th. &lt;strong&gt;Keyword search made it worse than no retrieval at all&lt;/strong&gt; — 8 abstentions — because the passages that match the words rarely carry the effective date, the legal status or the instant that decides the answer. Every answer and verdict is on &lt;a href="https://clockwrit.vercel.app/eval" rel="noopener noreferrer"&gt;the eval page&lt;/a&gt;.&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%2Ffbk6jsq9v2myk230yx6v.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%2Ffbk6jsq9v2myk230yx6v.png" alt="Eval" width="800" height="944"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What surprised me (and what I'm not claiming)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Knowledge Base is smarter than I planned for.&lt;/strong&gt; My first test fed it a news headline ("Rada abolished the clock change") and the bill card ("never signed"). It didn't raise a conflict — it wrote "adopted but not in force", which is &lt;em&gt;correct&lt;/em&gt;. Conflicts only appear when claims truly can't both be true. That's why the second import pass matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It doesn't rate authority on its own.&lt;/strong&gt; Conflict sides came back with no authority tier, and in one test it rated a community source as primary. Clockwrit takes authority from the typed records instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A full rebuild restructures every entry.&lt;/strong&gt; Decisions survive (issues keep a stable fingerprint, instructions persist) — the entry paths don't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The legal date and tzdata disagree on purpose.&lt;/strong&gt; For Manitoba and the NWT the dataset records the legal effective instant; the UTC offset is identical either way, only the DST flag and abbreviation differ.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not claimed:&lt;/strong&gt; global coverage (47 zones and 58 jurisdictions where something changed or is contested; elsewhere it says so), discovery of unknown facts (the eval measures whether structure gets the agent to the right answer), legal advice. The full ledger is &lt;a href="https://gitlab.com/RaYYeR220/clockwrit/-/blob/main/docs/CLAIMS.md" rel="noopener noreferrer"&gt;docs/CLAIMS.md&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sanity Project Details
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Project ID:&lt;/strong&gt; &lt;code&gt;c9x90tjo&lt;/code&gt; — dataset &lt;code&gt;production&lt;/code&gt; (public)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public dataset query:&lt;/strong&gt; &lt;a href="https://c9x90tjo.api.sanity.io/v2026-09-01/data/query/production?query=*%5B_type%3D%3D%22instrument%22%5D%7Btitle%2Cauthority%2Cstatus%2Curl%7D" rel="noopener noreferrer"&gt;every instrument with its authority and status&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Studio:&lt;/strong&gt; &lt;a href="https://clockwrit.sanity.studio" rel="noopener noreferrer"&gt;https://clockwrit.sanity.studio&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context MCP endpoints:&lt;/strong&gt; &lt;code&gt;clockwrit-catalog&lt;/code&gt; (dataset, GROQ mode), &lt;code&gt;clockwrit-sources&lt;/code&gt; (Knowledge Base mode)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use it from your own agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http clockwrit https://clockwrit.vercel.app/api/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Agent Session
&lt;/h2&gt;

&lt;p&gt;No session transcript is attached to this entry.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>sanitychallenge</category>
      <category>sanity</category>
      <category>ai</category>
    </item>
    <item>
      <title>I built an agent that reads 200-year-old handwriting — the interesting part is what it refuses to do</title>
      <dc:creator>Kateryna Ivashchenko</dc:creator>
      <pubDate>Mon, 31 Aug 2026 23:48:38 +0000</pubDate>
      <link>https://dev.to/rayyer/i-built-an-agent-that-reads-200-year-old-handwriting-the-interesting-part-is-what-it-refuses-to-do-2i30</link>
      <guid>https://dev.to/rayyer/i-built-an-agent-that-reads-200-year-old-handwriting-the-interesting-part-is-what-it-refuses-to-do-2i30</guid>
      <description>&lt;p&gt;The National Archives Catalog holds 34,309,409 records. Most of the handwritten material in it has never been transcribed, which means it is not full-text searchable, which means it is effectively invisible: you can only find a document if you already know it exists. The people who fix that are volunteers in the Citizen Archivist programme, typing one page at a time.&lt;/p&gt;

&lt;p&gt;I spent a build cycle on a partner for that volunteer. Along the way I hit four things that cost me real time and that I have not seen written down anywhere, so here they are.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The catalog answers HTTP 200 with an HTML page when you are wrong
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;catalog.archives.gov&lt;/code&gt; exposes a full JSON API behind &lt;code&gt;/proxy/*&lt;/code&gt;. It is unauthenticated, it works from outside the US, and it is genuinely good. But it has a failure mode that will eat an afternoon: &lt;strong&gt;when your request is invalid, or when you have been going too fast, it returns &lt;code&gt;200 OK&lt;/code&gt; with the single-page app's HTML shell.&lt;/strong&gt; Not a 400. Not a 429. A 200, with a &lt;code&gt;text/html&lt;/code&gt; body, 5,454 bytes every time.&lt;/p&gt;

&lt;p&gt;So the only reliable success signal is the content type:&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;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content-type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&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;ShellResponse&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;   &lt;span class="c1"&gt;# retryable
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worse, &lt;code&gt;limit&lt;/code&gt; is not a range — it is an &lt;strong&gt;allowlist&lt;/strong&gt;: &lt;code&gt;{1, 10, 20, 50, 75, 100, 1000, 10000}&lt;/code&gt;. &lt;code&gt;limit=3&lt;/code&gt; and &lt;code&gt;limit=5&lt;/code&gt; both silently return that same shell. I lost about an hour to &lt;code&gt;limit=5&lt;/code&gt; before I checked the content type instead of the status code.&lt;/p&gt;

&lt;p&gt;Two more from the same afternoon: pagination is &lt;code&gt;page&lt;/code&gt;, not &lt;code&gt;offset&lt;/code&gt;; and &lt;code&gt;transcriptions_exist=true&lt;/code&gt; is accepted and then &lt;strong&gt;ignored&lt;/strong&gt; — it returns identical counts for &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;false&lt;/code&gt; and a nonsense value. Scope by series with &lt;code&gt;ancestorNaId&lt;/code&gt; instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The Gemini free tier is 20 requests per &lt;em&gt;day&lt;/em&gt;, and retrying makes it worse
&lt;/h2&gt;

&lt;p&gt;I assumed a 429 meant "slow down". It can also mean "come back tomorrow":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GenerateRequestsPerDayPerProjectPerModel-FreeTier   value: 20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty requests per day, per model, per project. My evaluation harness had a perfectly sensible retry policy — six attempts, exponential backoff — and it turned a spent budget into a &lt;em&gt;very&lt;/em&gt; spent budget, because every retry against a daily cap is another request from a bucket that is already empty.&lt;/p&gt;

&lt;p&gt;The fix is to tell the two apart before deciding to retry:&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;_DAILY_MARKERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;perday&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;per_day&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;requestsperday&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;free_tier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;freetier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_is_daily_quota&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&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="s"&gt;429&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_DAILY_MARKERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A per-minute limit clears if you wait. A per-day limit does not, and retrying it is pure waste.&lt;/p&gt;

&lt;p&gt;The way out, incidentally, is not a bigger free tier — it is &lt;strong&gt;Vertex AI&lt;/strong&gt;, which bills through Cloud and has no daily cap of this kind. Which leads to:&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Vertex and AI Studio are not the same API, and one model lives on only one of them
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;google-genai&lt;/code&gt; gives you one &lt;code&gt;Client&lt;/code&gt; for both, which makes it easy to assume they are interchangeable. They are not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Interactions API&lt;/strong&gt; (&lt;code&gt;client.interactions.create&lt;/code&gt;) is the current AI Studio surface and it supersedes &lt;code&gt;generate_content&lt;/code&gt;. On Vertex it answers &lt;code&gt;400 Unsupported model interaction: gemini-3.7-flash&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;So on Vertex you use &lt;code&gt;generate_content&lt;/code&gt; with &lt;code&gt;response_mime_type&lt;/code&gt; + &lt;code&gt;response_schema&lt;/code&gt; — the pair that is marked &lt;em&gt;deprecated&lt;/em&gt; on the AI Studio path in favour of &lt;code&gt;response_format&lt;/code&gt;. Both are correct; which one is correct depends on the endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemma is not a Vertex publisher model.&lt;/strong&gt; &lt;code&gt;gemma-4-31b-it&lt;/code&gt; is a plain 404 there — on Vertex it only exists behind a Model Garden deployment you provision and pay for. The AI Studio endpoint serves it directly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I run Gemini on Vertex and Gemma on AI Studio in the same process, which needs one more thing that is easy to miss: &lt;code&gt;GOOGLE_GENAI_USE_VERTEXAI&lt;/code&gt; is read process-wide, so a client you construct with &lt;code&gt;api_key=&lt;/code&gt; still routes to Vertex unless you say &lt;code&gt;vertexai=False&lt;/code&gt; explicitly.&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;client_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemma&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;on_vertex&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vertexai&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# explicit, or the env var wins
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;get_client&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. ADK 2.8: the workflow agents are deprecated, and tool confirmation does not work in a graph
&lt;/h2&gt;

&lt;p&gt;Two things changed under the tutorials.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;SequentialAgent&lt;/code&gt;, &lt;code&gt;ParallelAgent&lt;/code&gt; and &lt;code&gt;LoopAgent&lt;/code&gt; are all &lt;code&gt;@deprecated&lt;/code&gt; in 2.8&lt;/strong&gt;, superseded by &lt;code&gt;google.adk.workflow.Workflow&lt;/code&gt; — a real graph runtime. &lt;code&gt;BaseAgent&lt;/code&gt; now subclasses &lt;code&gt;workflow.BaseNode&lt;/code&gt;, so agents &lt;em&gt;are&lt;/em&gt; nodes and inherit &lt;code&gt;retry_config&lt;/code&gt;, &lt;code&gt;timeout&lt;/code&gt;, &lt;code&gt;rerun_on_resume&lt;/code&gt; and &lt;code&gt;state_schema&lt;/code&gt;. Retries and timeouts stop being wrapper code and become properties of the topology, which is a much nicer place for them.&lt;/p&gt;

&lt;p&gt;Two runtime behaviours you only find by running it: &lt;code&gt;ctx.run_node()&lt;/code&gt; raises unless the &lt;strong&gt;calling&lt;/strong&gt; node has &lt;code&gt;rerun_on_resume=True&lt;/code&gt;, and a &lt;code&gt;JoinNode&lt;/code&gt; placed behind a &lt;em&gt;conditional&lt;/em&gt; fan-out never completes, because it waits for all predecessors and one of them never runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the one that actually cost me a design:&lt;/strong&gt; &lt;code&gt;FunctionTool(fn, require_confirmation=True)&lt;/code&gt; — the human-in-the-loop gate — is implemented in the &lt;code&gt;LlmAgent&lt;/code&gt; flow, in &lt;code&gt;flows/llm_flows/request_confirmation.py&lt;/code&gt;. A &lt;code&gt;Workflow&lt;/code&gt;'s tool node builds a fresh &lt;code&gt;ToolContext&lt;/code&gt;, calls &lt;code&gt;tool.run_async&lt;/code&gt; and yields the result. It &lt;strong&gt;never emits &lt;code&gt;adk_request_confirmation&lt;/code&gt;&lt;/strong&gt; and never resumes. Drop a confirmation-gated tool into a graph and it will run straight through, silently, exactly as if you had not asked for a gate.&lt;/p&gt;

&lt;p&gt;What works: raise a workflow-native &lt;code&gt;RequestInput&lt;/code&gt; carrying the pending call, and on resume re-validate the arguments against a digest you recorded when approval was requested — &lt;em&gt;then&lt;/em&gt; hand it to the confirmation-gated tool. You keep the property that matters, which is that an approver cannot alter the call they approved.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I did not want to write
&lt;/h2&gt;

&lt;p&gt;The whole product is built on one idea: the agent should learn the volunteer's editorial conventions from their corrections, so the hundredth page costs less attention than the first.&lt;/p&gt;

&lt;p&gt;So I tested it properly. Mine conventions from corrections on 60 training pages, apply them to 60 pages the miner never saw, grade the same model reading twice — once raw, once styled — so the comparison is paired and sampling noise cannot move it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1 page improved. 57 unchanged. 2 worsened. Mean CER change: −0.0001.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Six conventions were mined, each at 100% precision on training data. They simply do not fire often enough on unseen pages to move a median. My best guess is the shape of the material: abbreviations in Revolutionary War pension files are long-tailed, so a clerk's contraction learned from one veteran's file rarely shows up in another. The experiment that would settle it learns &lt;em&gt;within a single file unit&lt;/em&gt;, where one hand repeats across dozens of leaves. I have not run it.&lt;/p&gt;

&lt;p&gt;An earlier version was worse. I had seeded plausible conventions by hand — &lt;code&gt;Recd&lt;/code&gt; → &lt;code&gt;Received&lt;/code&gt; — and it scored &lt;strong&gt;worse than doing nothing&lt;/strong&gt;, because archival practice is to transcribe verbatim. Expanding the abbreviation moves the text away from what the volunteer actually typed. Guessing at someone's editorial style is exactly the mistake the product exists to prevent, which is a funny way to learn a lesson.&lt;/p&gt;

&lt;p&gt;The transcription itself is fine — median CER 6.8% against transcriptions written by people, best page 0.29%. It is the learning claim that is unproven, and a result that only reports its wins is not a result.&lt;/p&gt;




&lt;p&gt;Code: &lt;a href="https://github.com/RaYYeR220/longhand" rel="noopener noreferrer"&gt;https://github.com/RaYYeR220/longhand&lt;/a&gt; · Live: &lt;a href="https://longhand-494617981995.us-central1.run.app" rel="noopener noreferrer"&gt;https://longhand-494617981995.us-central1.run.app&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
