<?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: Andrei Alba</title>
    <description>The latest articles on DEV Community by Andrei Alba (@andreialba).</description>
    <link>https://dev.to/andreialba</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%2F2975712%2F14758bb2-e2d2-48ef-94f8-aff63bda3c9b.png</url>
      <title>DEV Community: Andrei Alba</title>
      <link>https://dev.to/andreialba</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/andreialba"/>
    <language>en</language>
    <item>
      <title>Faux Pas Atlas: an etiquette guide with no verdict field</title>
      <dc:creator>Andrei Alba</dc:creator>
      <pubDate>Mon, 21 Sep 2026 08:51:30 +0000</pubDate>
      <link>https://dev.to/andreialba/faux-pas-atlas-an-etiquette-guide-with-no-verdict-field-4b6n</link>
      <guid>https://dev.to/andreialba/faux-pas-atlas-an-etiquette-guide-with-no-verdict-field-4b6n</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&lt;/a&gt;: Vibe-Code Something Strange.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Faux Pas Atlas&lt;/strong&gt; answers one question: &lt;em&gt;is it rude here?&lt;/em&gt; Tipping in Japan, a cappuccino after lunch in Italy, sitting next to a stranger on a Norwegian bus. Every travel guide answers these with one writer's opinion. This one refuses to. There is no verdict field anywhere in the database.&lt;/p&gt;

&lt;p&gt;A claim is one behaviour, in one place, in one situation. Anyone can attest to it with a single tap: rude, fine or depends, plus how they know the place. Locals count double, visitors half. A claim only reads as settled once five people have answered, three of them local, and 70% of the weighted voices agree. When locals disagree, the site shows the split instead of picking a side. Anyone can dispute a settled claim in writing, and a moderator rules on it: keep, split into narrower claims, or retire.&lt;/p&gt;

&lt;p&gt;The hook for visitors is the quiz: &lt;em&gt;Would you survive dinner in Portugal?&lt;/em&gt; Ten questions, built only from claims the locals have settled, with the review screen telling you who told us and how many of them.&lt;/p&gt;

&lt;p&gt;It is for travellers, expats, and above all the locals who correct them. The challenge framing was "an answer it can't afford to get wrong". My take: the only honest way to be sure is to never type the answer yourself.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Live site:&lt;/strong&gt; &lt;a href="https://faux-pas-atlas.xocoweb.workers.dev" rel="noopener noreferrer"&gt;https://faux-pas-atlas.xocoweb.workers.dev&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Try it in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Take the &lt;a href="https://faux-pas-atlas.xocoweb.workers.dev/quiz/portugal/" rel="noopener noreferrer"&gt;Portugal quiz&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Open any claim, for example &lt;a href="https://faux-pas-atlas.xocoweb.workers.dev/claim/italy-ordering-a-cappuccino-after-11-a-m/" rel="noopener noreferrer"&gt;ordering a cappuccino after 11 in Italy&lt;/a&gt;, which locals have split on. Add your voice. Reload and it is counted.&lt;/li&gt;
&lt;li&gt;Look at the &lt;a href="https://faux-pas-atlas.xocoweb.workers.dev/disputes/" rel="noopener noreferrer"&gt;contested board&lt;/a&gt;, where the atlas shows what it will not call.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://faux-pas-atlas.xocoweb.workers.dev/propose/" rel="noopener noreferrer"&gt;Propose a claim&lt;/a&gt;. It will not appear until a Function has checked it for sense and duplicates.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The moderator side, the Dispute Board, lives in the Sanity Dashboard behind the project login, so you cannot click into it from here. The Code section below describes what it does, and the repository has screenshots of it under &lt;code&gt;docs/screenshots&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Repository:&lt;/strong&gt; &lt;a href="https://github.com/andreialba/fauxpasatlas" rel="noopener noreferrer"&gt;https://github.com/andreialba/fauxpasatlas&lt;/a&gt; (MIT)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;studio/         Sanity Studio v6: schema, workflow definitions, seed scripts
web/            Astro 7 site on Cloudflare Workers; write endpoints under /api
functions/      Three Sanity Functions and the Blueprint that deploys them
dispute-board/  App SDK moderator app, deployed to the Sanity Dashboard
shared/         GROQ projections and the consensus rule, used by all of the above
docs/           Build log, screenshots, this post
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The schema, briefly
&lt;/h3&gt;

&lt;p&gt;Seven document types: &lt;code&gt;place&lt;/code&gt;, &lt;code&gt;context&lt;/code&gt;, &lt;code&gt;claim&lt;/code&gt;, &lt;code&gt;attestation&lt;/code&gt;, &lt;code&gt;dispute&lt;/code&gt;, &lt;code&gt;ruling&lt;/code&gt;, &lt;code&gt;attester&lt;/code&gt;. Two deliberate absences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;claim&lt;/code&gt; has &lt;strong&gt;no verdict field&lt;/strong&gt;. The verdict is a GROQ projection over attestations, computed on every read. The consensus rule lives in one TypeScript file that the site, the Functions and the moderator app all import, so they can never disagree about a number.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;attester&lt;/code&gt; has &lt;strong&gt;no stored trust score&lt;/strong&gt;. The moderator app derives it live: how often this person's past answers matched what locals later settled on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Context splits are first-class. "Kissing hello is fine socially and rude in business" is two claims sharing a parent, not a caveat in a text field. Retired claims stay in the dataset with their whole history.&lt;/p&gt;

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

&lt;p&gt;The &lt;code&gt;@sanity-labs/sanity-plugin-workflows&lt;/code&gt; Studio plugin provides two workflows: claim (Proposed → Attesting → Canon, with Contested and Retired as off-ramps) and dispute (Opened → Evidence → Ruling → Closed). The automatic transitions are Sanity Functions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;recompute-agreement&lt;/code&gt; runs on every attestation write and moves the claim between Attesting, Canon and Contested.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;claim-dedupe&lt;/code&gt; runs once per proposed claim. One Agent Actions &lt;code&gt;prompt&lt;/code&gt; call asks two questions: is this a genuine etiquette claim, and does it duplicate one already filed for the same place and situation? A clean pass opens for attestation. Anything else waits for a moderator. One credit per proposal, and the GROQ filter guarantees it never re-runs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;expiry-sweep&lt;/code&gt; runs daily and reopens settled claims nobody has confirmed in a year.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three write &lt;code&gt;workflow.setStatus&lt;/code&gt; entries, so their decisions show up in the plugin's Audit Trail inspector next to the human ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  App SDK: the Dispute Board
&lt;/h3&gt;

&lt;p&gt;A React app in the Sanity Dashboard, built on &lt;code&gt;@sanity/sdk-react&lt;/code&gt; 3 and Sanity UI 4. A live queue of every claim that needs a human: written disputes first, then the most split. For the selected claim: the weighted split, the breakdown by how well each voice knows the place, the written disputes, what people wrote, who is behind the numbers with each attester's track record, and a ruling pane. A ruling is &lt;strong&gt;one transaction&lt;/strong&gt;: the ruling document, the dispute it answers, the claim's new stage, and any narrower claims a split creates. Split claims enter as proposed and go through the same Agent Action check as a public proposal. Presence warns when another moderator is on the same claim. A second tab lists proposals the automatic check held back, with let-in and reject.&lt;/p&gt;

&lt;p&gt;Everything runs on the Free plan.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;IDE:&lt;/strong&gt; Claude Code in VS Code, with the Fable 5.1 model, over two days (20 and 21 September), then polish. I kept a build log from the first prompt: &lt;a href="https://github.com/andreialba/fauxpasatlas/blob/main/docs/build-log.md" rel="noopener noreferrer"&gt;docs/build-log.md&lt;/a&gt;. The plan it worked against, with checkboxes and a cut list, is &lt;a href="https://github.com/andreialba/fauxpasatlas/blob/main/PLAN.md" rel="noopener noreferrer"&gt;PLAN.md&lt;/a&gt;. What follows is the honest version.&lt;/p&gt;

&lt;h3&gt;
  
  
  What worked
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Decide first, then research, then a spec, then code.&lt;/strong&gt; Day one was no code at all. I went in with two decisions already made. The challenge framing was "an answer it can't afford to get wrong", and I wanted an app where that is literally true, where nobody is allowed to type the answer. And I wanted to go beyond Studio with both bonus features, the App SDK and Workflows, because a moderation workflow with a custom app for the people doing the moderating is the natural shape of that idea. Only then did I have the agent read the challenge page, the existing entries, the Sanity docs index, the pricing page and the workflows plugin README, and write it all into &lt;code&gt;CLAUDE.md&lt;/code&gt;: judging criteria in order, what the Free plan allows, what the other Path 2 entries were doing. That research confirmed the direction rather than setting it. The concept itself, an etiquette atlas where the verdict is computed from local attestations, came from a long back-and-forth with the model about what a &lt;em&gt;contested&lt;/em&gt; truth would look like as a schema, with me steering toward the version that had no verdict field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reading the plugin's compiled schema instead of clicking.&lt;/strong&gt; Rather than set up workflows in the Studio UI, the agent read the workflows plugin's bundled schema and workflow-kit's &lt;code&gt;WORKFLOW_QUERY&lt;/code&gt; to learn the exact document shape, then seeded two &lt;code&gt;workflow.definition&lt;/code&gt; documents by script. Along the way it learned that &lt;code&gt;status&lt;/code&gt; is a plain string and &lt;code&gt;statuses&lt;/code&gt; is an array the plugin reads, which is what let the Functions and the App SDK app write to the same audit trail later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A checkpoint that fails is worth more than one that passes.&lt;/strong&gt; The Phase 4 checkpoint was "rule on a dispute in the Dashboard, watch the live site update." The agent turned that into a script that files a ruling with the board's exact transaction and polls the live page. It failed. The claim was retired, the Sanity CDN agreed, and the site kept serving the page for minutes. Cause: Cloudflare caches a Worker's outbound requests by the origin's &lt;code&gt;Cache-Control&lt;/code&gt;, and Sanity's CDN sends &lt;code&gt;s-maxage=60&lt;/code&gt; with stale-while-revalidate. The fix was one line, &lt;code&gt;useCdn: false&lt;/code&gt;. A checkpoint done by eye in the Dashboard would have passed and shipped the bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The prompt that unlocked the App SDK.&lt;/strong&gt; The official &lt;code&gt;app-sanity-ui&lt;/code&gt; template ships with a Claude skill describing the SDK's hook patterns. Telling the agent to read that before writing a line saved most of the friction. Most, not all: see below.&lt;/p&gt;

&lt;h3&gt;
  
  
  What did not
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Shell heredocs, four times.&lt;/strong&gt; Writing several files in one shell command with heredocs failed on day one ("unexpected EOF while looking for matching quote"). It happened again with a 90-line &lt;code&gt;node -e&lt;/code&gt; codemod, again with a Markdown edit that mangled backticks, and again on the last day with a Node patch script whose template literals contained template literals. Each time the fix was the same: scripts go in files. The agent logged the lesson every time and still reached for the shortcut the next time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;@sanity/icons&lt;/code&gt; does not export &lt;code&gt;PinIcon&lt;/code&gt;.&lt;/strong&gt; First guess: stale Vite cache, because the package had been added after the dev server pre-bundled. Clearing it changed nothing. The real cause was that icons 5.x moved every icon to a subpath. The first guess had looked right because a grep of the &lt;code&gt;.d.ts&lt;/code&gt; matched an icon-name map. Lesson written into the log: a type-level grep is not proof of a runtime export.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sanity UI 4 breaks in ways the error messages hide.&lt;/strong&gt; &lt;code&gt;space&lt;/code&gt; became &lt;code&gt;gap&lt;/code&gt; and is typed &lt;code&gt;never&lt;/code&gt;, so the error reads "Type 'number' is not assignable to type 'undefined'" rather than anything about a renamed prop. Same for &lt;code&gt;useToast&lt;/code&gt;, which moved to &lt;code&gt;@sanity/ui/toast&lt;/code&gt; and left a &lt;code&gt;never&lt;/code&gt; stub behind. One codemod fixed the props; the agent had to read the package's type definitions to find where the toast went.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two SDK functions with the same shape and different arguments.&lt;/strong&gt; &lt;code&gt;editDocument&lt;/code&gt; takes patch operations. &lt;code&gt;useEditDocument&lt;/code&gt; takes an updater function. The first ruling pane passed an updater to &lt;code&gt;editDocument&lt;/code&gt; and the types did not catch it because of the Typegen registration issue below. The fix also improved the design: appending to the &lt;code&gt;statuses&lt;/code&gt; array with &lt;code&gt;insert after statuses[-1]&lt;/code&gt; instead of rewriting it, so two moderators acting at once cannot drop each other's history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Typegen registration for the App SDK did not take.&lt;/strong&gt; &lt;code&gt;createDocument&lt;/code&gt; types its initial value against an experimental module augmentation. Registering our generated types the documented way, including installing &lt;code&gt;groq&lt;/code&gt; so the augmentation could resolve, left every field typed as &lt;code&gt;never&lt;/code&gt;. Settled for one cast behind a single helper with the reason written next to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent Actions want &lt;code&gt;apiVersion: 'vX'&lt;/code&gt;.&lt;/strong&gt; A dated API version works for queries and mutations and returns 400 for the agent endpoints. The dedupe Function now keeps a second client just for the prompt call. The fallback path proved itself by accident: when the action failed, the claim was marked "needs a human look" rather than waved through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every deploy was silently deleting the write token.&lt;/strong&gt; &lt;code&gt;wrangler deploy&lt;/code&gt; clears vars before applying the config unless &lt;code&gt;keep_vars&lt;/code&gt; is set, and the Astro adapter's generated config carries an empty &lt;code&gt;vars&lt;/code&gt; block. The first deploy after &lt;code&gt;wrangler secret put&lt;/code&gt; worked and every one after it broke all three write endpoints. Found while answering a question about tampering, not while testing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A duplicate check is not a content check.&lt;/strong&gt; On the last day I posted "A test claim" through the public form. The Function found no duplicate, which was true, and promoted it to the atlas. The fix was to make the same Agent Action prompt answer two questions instead of one, hold anything that fails, and give the moderator app a Proposals tab. Sent the test claim back through: held in five seconds with the reason "test text rather than a real, specific social behaviour".&lt;/p&gt;

&lt;h3&gt;
  
  
  What was cut, and what is honestly weak
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Visual Editing&lt;/strong&gt; went first. &lt;code&gt;@sanity/astro&lt;/code&gt; wanted React, &lt;code&gt;sanity&lt;/code&gt; and styled-components as peers for the overlays alone; the site uses &lt;code&gt;@sanity/client&lt;/code&gt; directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Workflows engine&lt;/strong&gt; (early access) never entered the picture. Everything runs on the Studio plugin plus our own Functions. The App SDK app works without the engine, which was a constraint from day one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Locality is self-declared.&lt;/strong&gt; We trust people and show the split. The trust panel in the moderator app exists so a suspicious spread can be read, not just counted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One person, one vote&lt;/strong&gt; is enforced by a stable id per name or address. It stops the casual double vote. It does not stop someone with a script and a VPN, and the writeup should not pretend otherwise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attestation notes are published as written.&lt;/strong&gt; Proposals are gated by a model and a moderator. Notes are not, yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The seed content was drafted by the model&lt;/strong&gt; from common travel-etiquette knowledge and reviewed by hand. It is a starting point. A site whose whole premise is that locals should correct the guide can live with that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free plan roles.&lt;/strong&gt; Only Administrator and Viewer exist, so "moderator" is a workflow role, not a Sanity permission.&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;dh2oc30x&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dataset:&lt;/strong&gt; &lt;code&gt;production&lt;/code&gt;, public&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Try a query with no token:&lt;/strong&gt; &lt;a href="https://dh2oc30x.api.sanity.io/v2026-04-12/data/query/production?query=*%5B_type==%22claim%22%5D%5B0..4%5D%7Bstatement,status%7D" rel="noopener noreferrer"&gt;https://dh2oc30x.api.sanity.io/v2026-04-12/data/query/production?query=*[_type=="claim"][0..4]{statement,status}&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dispute Board:&lt;/strong&gt; in the Sanity Dashboard for the project's organisation. Test access on request; the Free plan has no read-only moderator role to hand out.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Solo entry. No teammates to credit.&lt;/p&gt;

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