Follows [[IMPLEMENTATION-NOTES-2026-07-30-onoma-v0]]. Founder ask (iMessage, 2026-07-30 17:05 + 2026-07-31 08:54): cache results so repeat names are not regenerated, key on name type, reuse name parts across names, add autocomplete over previously-requested names, and make the cache global so visitors without a key can explore. Framing constraint from him: "this is just a fun project... we don't have to over complicate it."
Branch v1-global-cache on RayDataCo/onoma.
The shape: reads free, writes BYOK
v0 was a pure static page — the visitor's key went browser-direct to api.anthropic.com and nothing else existed. Global caching needs a backend, so v1 adds a Cloudflare Worker + D1 which also serves the static app (assets binding), replacing the quick-sites static deploy.
The load-bearing decision: the Worker never sees anyone's API key. Generation stays browser-direct exactly as in v0; the Worker is only a cache store. So:
- Full cache hit → served to anyone, keyless, free. This is the founder's "other people could explore without adding a token."
- Miss / partial → needs the visitor's own key; their browser calls Anthropic, then POSTs the result back for everyone after them.
This preserves v0's best property (no key custody, no server-side secret) while getting the global behavior he asked for.
Decisions
- Two granularities, two tables.
nameskeyed(input_norm, name_type)for whole-string hits;partskeyed(token_norm, name_type)so "Wilson" is derived once and every later Wilson is free — his exact partial-reuse idea. Verified:Andrew Herman WilsonafterBenjamin Andrew Wilsonreturnshit: partial, reusing Andrew + Wilson, missing onlyherman. - Type keying stores the RESOLVED type, not the requested one. When a user picks "other", the system prompt makes the model replace it with an inferred category (
product,racehorse). Caught during the build that a fixed enum in the validator would reject exactly those — so stored types are validated by shape (/^[a-z][a-z -]{1,38}$/), with literalotherrejected as an unresolved type no typed lookup could reach. Verifiedracehorseaccepted,otherrejected. - Normalization folds case, whitespace, and diacritics.
benjamin ANDREW wilsonhits the row created byBenjamin Andrew Wilson. - Partial-reuse prompt path. Known part cards are passed to the model as SETTLED, to be echoed verbatim, with derivation spent only on missing elements. It still returns the full payload — echoing is cheap and it keeps one render path.
reading/synthesisare explicitly not reused: they are claims about this particular combination of parts, not about any single part. INSERT OR IGNORE, notREPLACE. First derivation of a part wins. On an open write endpoint, last-write-wins would let anyone overwrite a good entry with a worse one.- Provenance is shown, not hidden — the result eyebrow reads "from the shared cache" or "reused N parts from the shared cache". Where a reading came from is part of what makes a shared cache legible.
Privacy — the one call escalated to the founder
A global cache makes every submitted name world-readable, and people type their own family's names. Resolution: the line is drawn at suggestability, not storage. Multi-word person names are stored and servable but flagged suggestable = 0, so they never surface in autocomplete. Their parts (Benjamin, Andrew, Wilson — dictionary entries, nothing personal) and all non-person names stay fully browsable.
Verified: q=benj returns only Benjamin (part), never Benjamin Andrew Wilson; q=amst returns both Amsterdam and Amstel.
Flagged to founder 2026-07-31 08:5x with the alternative (fully-open autocomplete) as his call. Built the conservative option because it is the reversible direction — opening it later is a one-line change, un-publishing names is not.
Abuse surface (new in v1, did not exist in v0)
/api/contribute is unauthenticated by design — the whole model is that any visitor with a key can feed the shared cache. Mitigations, all verified live:
| Control | Result |
|---|---|
| Full server-side re-validation of payload shape | {} → 400, bad role enum → 400, beat/part count mismatch → 400 |
| Body cap 24KB | 40KB synthesis → 413 |
| Angle brackets rejected in all string fields | <img src=x onerror=...> → 400 bad part token |
| Per-IP rate limit, 20 writes / 60s, salted-hashed IP | 25 rapid writes → 429 after the window filled |
INSERT OR IGNORE |
cannot overwrite existing entries |
XSS specifically: grepped the whole client for innerHTML / outerHTML / insertAdjacentHTML / document.write / eval — zero sinks, only a comment mentioning the rule. Cached content is inert even before the bracket rejection. The bracket rule is anti-vandalism (inert markup rendering as literal text in a shared card), not the XSS control.
Threat model genuinely changed here and is worth stating: in v0 all rendered content came from the user's own API call. In v1 it comes from strangers.
Tradeoffs / known limits
- Sub-word tokenization is not predicted. The model may split one word into elements (
Amsterdam→ Amstel + dam), which whitespace tokenization cannot anticipate. Those names miss the part cache and hit the whole-name cache instead. Multi-word names — where reuse actually pays, and his stated use case — tokenize correctly. Accepted rather than fixed; fixing it means a second model call just to tokenize. - A part's compression beat is inherited from whichever name first derived it. This is the intended reuse, but it means a beat is occasionally tuned to its original context.
- Rate limit is per-IP and D1-backed, so it is best-effort, not a hard guarantee under a distributed writer.
NOT verified (honest gap)
- The partial-reuse prompt path has never run against the live model. Its plumbing is verified end to end (cache returns the right known parts, the client passes them into
buildRequest, the prompt text is constructed correctly), but no real API call has exercised whether the model actually echoes settled parts verbatim rather than quietly re-deriving them. Same class of gap as v0's live-output acceptance: it needs a real key. This is the first thing to check once a key is in hand. - Design-critic has not re-scored the v1 surface (autocomplete dropdown is new UI). v0's round-2 critic also never completed.
Explicit gate before any public opening
Founder, 2026-07-31 09:05: "this is still toy/demo territory. If we were really going to open it up to the public we would need to add in some bad word detection and such." Agreed and recorded as a gate, not a backlog item.
The surface stays Cloudflare-Access-gated. The cache being "global" currently means global among people who can already reach the app, which today is the founder. Nothing built here opens the door on its own.
What a genuine public launch would need beyond what exists:
- Input moderation on the submitted name — the autocomplete is the exposure, since a slur typed once becomes a suggestion served to everyone.
- Output moderation on the derived reading before it enters the shared cache.
- A takedown path — currently no way to delete a cached row without hand-editing D1.
- Reconsideration of whether person-name parts should be browsable at all at public scale.
Items 1–3 do not exist. Do not treat the Access gate coming off as a config change.
Verification log
Local wrangler dev + D1 (migrations/0001_cache.sql applied clean, 7 commands). Endpoint tests via curl, browser tests via Playwright at 1200px and 393px.
- Cold miss →
{"hit":"miss"}· contribute →{"stored":true}· warm lookup →hit: fullwith all 3 parts and beats intact. - Keyless full render (the core claim):
localStoragecleared and confirmed empty, submittedBenjamin Andrew Wilson→ complete reading rendered, eyebrow "Reading · person · 3 parts · from the shared cache", key still absent afterward, andperformance.getEntriesByType('resource')shows 0 requests to anthropic across the whole session. - Uncached + keyless → reworded "not read yet" notice (no longer implies the app needs a key to do anything) + settings auto-open. Partial variant renders its own distinct copy.
- Autocomplete: opens, filters, 44px rows, person full-names absent, place names present.
- Mobile 393px: no horizontal overflow, dropdown 311px inside a 393px viewport, 44px tap targets.
- Console: 0 errors, 0 warnings (one Chrome verbose DOM advisory about the password field, pre-existing from v0).
Copy correction made in passing
v0's settings fineprint claimed "nothing passes through RDCO servers." That became false the moment a cache existed — lookups and contributions do transit an RDCO Worker (the key still does not). Reworded to say exactly what is and is not shared, rather than leaving a privacy claim that had quietly stopped being true.