DEV Community

Cover image for Cryptonym Desk on Sanity: moving a word list into a dataset found two bugs
Christian Anderson
Christian Anderson

Posted on

Cryptonym Desk on Sanity: moving a word list into a dataset found two bugs

Sanity Challenge Path Two Submission

This is a submission for the Sanity Challenge, Path Two: Vibe-Code Something Strange

What I Built

Cryptonym Desk gives you a spy file for your agent. You type in films, anime and characters you like. It gives you back three names: a CIA-style cryptonym (a real two-letter office digraph plus a word that means nothing, like AEDINOSAUR or LIENVOY), a working alias made by grafting your names together at their vowels, and an ADJECTIVE NOUN field codename.

The first version was one 29 KB HTML file with every word list hard-coded in a <script> tag. For this challenge I moved the lists into a public Sanity dataset and rebuilt the page in Astro, which reads that dataset when the site is built.

It's for people who name things: agents, bots, side projects, D&D characters. It doesn't take itself seriously. The one thing I kept strict is that nothing you type ever leaves the page.

Demo

The desk issuing a record from the Space western preset

The corpus page: every digraph, word and weight, plus the GROQ query the page was built with

Code

https://github.com/casareanderson/cryptonym-desk-sanity (MIT). The original single-file version is at https://github.com/casareanderson/cryptonym-desk.

My Build Process

The tool: Claude Code, in a terminal, not an IDE. I gave it a short brief and it did the build end to end: import, schema, Astro port, tests, GitHub Action, deploy. I reviewed the result. What went wrong along the way is more useful than what went right, so this section is mostly that.

1. The schema came first, and the data got cleaner as a result. The three document types are digraph (code, provenance note, retired flag), wordBank (bank, value, weight) and preset (label, seed list). The corpus came to 103 documents. Just turning the arrays into documents exposed a bug: MERIDIAN was in the noun list twice, so it had been drawn twice as often as any other noun. In a JavaScript array you never notice that. As separate documents, it stands out immediately.

2. A test written from the README failed, and the README was the thing that was wrong. The README says the splitter cuts Spiegel into Spie·gel and Kusanagi into Ku·sa·na·gi, so Spiegel × Kusanagi gives Spienagi. The agent wrote a test for exactly that. It failed. The regex had always kept one consonant after each vowel group, giving Spieg·el and Kus·an·ag·i, so across 200 seeds that pair only ever produced Spiegagi. The README's own example could not happen. The obvious fix was to change the test to match the code. Instead we treated the documented behaviour as the design and the regex as the bug. The code now matches the README, and there's a test that the README's example can actually be produced. The cost: the same inputs now give different aliases than the original desk did.

3. The weight field was decoration at first. The schema had it and the generator did weighted picks, but every entry weighed 1, so none of it did anything. I only noticed when the corpus page showed a column of ×1. The fix was an edit in the dataset, not the code: plain SECRET weighs 5 and CODE WORD weighs 0.5. That edit is also the end-to-end proof. The corpus revision printed on the live site went from 211eb8a to 4ca7fee on the next build, with no code change.

4. Every record now carries the corpus revision. Records have always been seeded (same inputs plus the same salt gives the same record), which is what makes a "file reference" mean something. Once the words live in a dataset, the same inputs can produce a different record after someone edits a bank. So the page hashes every document's _rev into a 7-character revision and prints it on the record and in "Copy record". Without it, "same inputs, same record" would quietly stop being true.

5. The build refuses a corpus that would break the page. If a bank is empty, a bank name is unknown, a weight is zero or no digraphs are active, astro build fails, so the page can't ship and then throw on click. The Studio schema enforces the same rules earlier (positive weights, uppercase 2–3 letter codes, at least two seeds per preset).

6. What I deliberately didn't do. The corpus is fetched at build time only. The page makes no runtime calls to Sanity, because it has always promised that nothing you type leaves the browser, and I'd rather keep that promise than add a live query. Edits reach the site through a GitHub Action that runs on push, on demand and nightly. A Sanity webhook would be faster, but it would mean storing a GitHub token inside Sanity, and a nightly rebuild is plenty for a word list.

7. Where it stopped, and the update. The first version was a read-only frontend, and I said the obvious next step was a Workflow that lets people propose words, with a person approving each one into a bank. That's now built (update, 25 September):

Update: word proposals, a workflow kept as data

Every proposal is a wordProposal document in the same public dataset: the word, the target bank, a weight, a rationale, and a status that moves through proposed → in review → approved → merged (or rejected, which can be reopened). Each move appends to a history[] {status, at, by, note} array on the document, so one GROQ query answers "who approved CISTERN, and when?". There's no second database for the workflow state.

The App SDK review queue: five lanes (Proposed, In review, Approved, Rejected, Merged), each card showing the word, its rationale, the reviewer's note and the last move

  • One rules module, three surfaces. An App SDK review board, Studio document actions and a CLI all call the same dependency-free src/lib/proposals.js. None of them can skip review: proposed → merged is refused. A rejection needs a reviewer note.
  • The board is deployed as a Sanity App SDK app ("Word proposals"), and organisation members can open it from the Sanity Dashboard.
  • The board updates live. It uses useQuery from @sanity/sdk-react, which subscribes to changes. A move made from the CLI showed up on the open board in about 2.6 seconds, with no reload.
  • The merge is deterministic code in one transaction. It does a createIfNotExists for the new wordBank entry (a predictable _id, so a double click lands on the same document) plus a patch to the proposal pinned with ifRevisionID. If two reviewers merge at once, only one succeeds. I tested this against the live API: the stale merge got a 409 and wrote nothing.
  • It catches the bug the port found. A proposal for a word already in the bank links to the existing entry instead of creating a second one. That's exactly how MERIDIAN ended up drawn twice as often in the original.
  • Proposals don't change the corpus revision. Moving cards around leaves everyone's CORPUS stamp alone. Only a merge changes the words, so only a merge changes the stamp.

A proposal in the Studio mid-review: status, reviewer note and the append-only history

The loop, run for real: CISTERN went proposed → in review → approved → merged, and it's now in the noun bank of the live site. There are 15 nouns instead of 14, and the corpus revision changed with no code change.

The corpus page with CISTERN in the noun bank

What went wrong: sanity@latest (v6) needs Node 22.12 or later, and the box has Node 20. The v4 CLI's unattended init --template app-quickstart asks for flags it then refuses to accept together, so the app was written by hand from the template the CLI bundles. A project robot token can deploy a Studio but not an App SDK app, which needs an organisation-level grant. And the tradeoff of a public dataset: proposals, rationales and reviewer notes are world-readable, like the rest of the corpus. The Studio actions patch the published document and are disabled while a draft exists, so an unsaved edit can't be silently left behind.

Tests: 19 in total (10 new). They cover the transition table, skipped-review refusal, the rejection note, the predictable merge _id, the duplicate guard and the one-transaction revision pin.

One honest gap in the data is still there: each digraph's provenance note is a generic "real digraph from declassified material" line, not an individual citation.

The rest of this section is from the original build.

Small things that went wrong: The Studio's first sanity deploy stopped and asked for the new app ID to be pinned in sanity.cli.js. The headless screenshot step first tried a Python Playwright that wasn't installed, then used the Node one. And GitHub Actions now warns that Node 20 actions are deprecated.

Tests: 9 node:test cases. They cover determinism, the revision on each record, weighted-pick distribution (10,000 draws), split parts rejoining to the original word, the README example actually appearing, and the corpus validator rejecting each kind of bad data.

Sanity Project Details

Agent Session

Not embedded. The session that built this ran on my homelab box, and the transcript is full of private network detail that has nothing to do with the build. I'd rather leave it out than publish a heavily redacted version. The repo's commit history, tests and this write-up are the record.


🤖 Built with an AI coding agent (Claude Code) and drafted with AI assistance from the build notes, commits and test output, then reviewed before publishing.

Top comments (0)