ADR-057 — Narrow exception to "no client storage": one functional cookie for explicit user-set locale override
Status · Accepted Date · 2026-05-10 Supersedes (in part) · TA.md "no cookies for user preferences" line (lines 159 and 281); narrows the absolute prohibition to a scoped exception Related ADRs · ADR-017 (i18n architecture), ADR-024 (URL-as-state for missions), ADR-029 (PWA + visit-counter in runtime memory), ADR-044 (URL-as-state for locale), ADR-055 (Science Lens — runtime-only) Closes · Issue #73 Gap 2 (cross-session persistence for explicit locale override)
Context
Issue #73 surfaced a friction point in Orrery's "no client storage" stance: a user who explicitly picks a non-default locale via the LocalePicker loses that preference on a fresh visit. Auto-detection from navigator.language works on first paint (already shipped in src/lib/locale.ts:resolveLocale), and bookmarks carry ?lang= cleanly, but a user who overrides their browser default and later types the bare URL again falls back to navigator.language.
Five existing ADRs and TA.md treat client storage as a hard non-negotiable:
- TA.md:159 — "No user data. No accounts. No login. No
localStorage. NosessionStorage. No cookies for user preferences. State is session-only and resets on reload." - ADR-017 — i18n architecture: locale lives in
?lang=URL param. - ADR-024 — mission URL contract:
/fly?mission=idis the only mission state; rejected "last visited mission" because it would need localStorage. - ADR-029 — PWA visit counter held in runtime memory only "per CLAUDE.md 'do not use localStorage'."
- ADR-044 — "Keep
?lang=as locale source-of-truth; no storage-based locale state." - ADR-055 — Science Lens deliberately ephemeral; lens-on resets on every page load. Considered a cookie, rejected as "fights 'no localStorage' spirit."
The rule has paid dividends: the codebase has stayed disciplined, no GDPR cookie banner is needed, the app has zero compliance surface, multi-device sharing works because URL = canonical state. Removing the rule wholesale would invite scope creep — once one cookie exists, "just one more for X?" becomes a recurring conversation that erodes the simplicity.
Decision
Allow exactly one cookie, scoped narrowly to the case the absolute rule was actively hurting.
The exception
| Field | Value |
|---|---|
| Name | orrery_locale |
| Value | A supported locale code from SUPPORTED_LOCALES (e.g. nl, de, sr-Cyrl) — never anything else |
SameSite | Lax |
Secure | true (production builds; omitted on http://localhost for dev) |
HttpOnly | false (must be readable from document.cookie — there is no server) |
Max-Age | 31536000 (365 days) |
Path | / |
| Set when | The user explicitly clicks a locale in LocalePicker.svelte |
| Never set when | Auto-detection runs (URL canonicalisation per #73 Gap 1 stays cookie-free); page load; SW install; any non-explicit interaction |
Resolution precedence (updates src/lib/locale.ts:resolveLocale)
?lang=in URL — wins always (preserves URL-as-truth + share-link semantics).orrery_localecookie — new, sits between URL and browser.navigator.language(vianormaliseBrowserLocale) — unchanged.DEFAULT_LOCALE(en-US) — unchanged.
Constraints on what this exception unlocks
This ADR is explicitly scoped to locale overrides only. It does not open the door to:
- Cookies for Science Lens state (ADR-055 still binding).
- Cookies for last-visited mission (ADR-024 still binding).
- Cookies for filter state on
/missionsor/library. - Cookies for PWA install-prompt visit counter (ADR-029 still binding).
- Any analytics / tracking cookie of any kind.
- Any cookie that stores anything other than a single locale code.
Future expansion to cover any of the above requires its own ADR with explicit reasoning. The default answer for any new "should we cookie X?" question stays no.
Compliance
The cookie is a strictly necessary / functional preference cookie under ePrivacy Directive (EU 2002/58/EC, as amended) and GDPR Recital 30. It stores only a UI preference the user explicitly set, contains no personal data, no identifier, no tracking. No consent banner is required. Documented in /credits (or a new /privacy blurb if the landing-page work in #74 adds one).
Implementation surface
src/lib/locale.ts— addreadLocaleCookie()andwriteLocaleCookie()helpers; extendresolveLocale()to consult the cookie between URL andnavigator.language.src/lib/components/LocalePicker.svelte— callwriteLocaleCookie(code)on click, alongside the existing URL update.src/lib/locale.test.ts— unit tests for the resolution precedence and the cookie helpers.tests/e2e/i18n-cookie-persistence.spec.ts— new e2e: pick Italian → fresh navigation to bare URL → still Italian.- Update
CLAUDE.md"Do not uselocalStorageorsessionStorage" line to add a parenthetical: "and no cookies for user preferences except as narrowly permitted by ADR-057 (explicit locale override only)." - Update
docs/adr/TA.mdline 159 with the same parenthetical + a link to this ADR. - Update
docs/adr/index.mdto add this ADR.
Estimate: ~80 LOC across helpers + LocalePicker; 3 unit tests; 1 e2e test; 4 doc updates.
Rationale
The 2026 norm for locale persistence is functional cookie + URL precedence + navigator.language fallback (GitHub, Google, Wikipedia, MDN, Cloudflare-fronted sites all do this). localStorage itself has fallen out of fashion industry-wide for state of this kind — synchronous API, 5 MB string-only limit, replaced by IndexedDB or just-not-stored for most modern apps. The principled path is therefore not "relax the rule to allow localStorage" but "narrowly allow the modern, tightly-scoped pattern."
Keeping the cookie scope to a single key with a single allowed value class is the difference between "Orrery has a preferences subsystem" (slippery slope) and "Orrery remembers one explicit user choice" (bounded, defensible, easy to remove).
Alternatives considered
- Status quo (URL-only). Simplest. Costs one user friction point (override loss on fresh URL) that 95%+ of users will never hit because auto-detect already serves them correctly. Revisit if user feedback shows the friction matters.
localStorageinstead of cookie. Slightly cleaner API but: (1) blocks main thread, (2) industry has moved away, (3) no advantage over a cookie for a single-string preference, (4) feels heavier given the rule history.- IndexedDB. Massively over-engineered for one string. Reject.
- Service Worker cache as state store. Misuses the SW. Reject.
- Server-side (e.g. Cloudflare Worker setting
Set-Cookie). Requires a backend. Violates TA.md "browser-only" non-negotiable. Reject. - Open the rule wholesale ("allow client storage where useful"). Loses the discipline that has kept the codebase clean. Reject — preserve the "default is no" stance.
Consequences
Positive:
- Closes the only legitimate friction the absolute no-storage rule was creating.
- Keeps Orrery's "share by URL" promise intact (URL still wins).
- Preserves zero-tracking / no-banner stance.
- Establishes a clear precedent for narrow exceptions: one cookie, one purpose, one ADR each.
Negative:
- Introduces a storage primitive that the project had been disciplined about avoiding. Future "just one more cookie?" requests must be refused absent their own ADR — the maintainer must enforce this.
- Adds ~80 LOC + a doc to maintain.
- Adds a test surface (the e2e cookie-persistence spec) that needs Playwright cookie context.
- Slightly complicates the "I cleared my cookies" debugging story — users who clear cookies will revert to
navigator.language. Acceptable; clearly documented in/credits.
Decision status
Accepted on 2026-05-10. Implementation lands in the same PR — see src/lib/locale.ts (readLocaleCookie / writeLocaleCookie / updated resolveLocale precedence), src/lib/components/LocalePicker.svelte (cookie write on pick()), src/lib/locale.test.ts (unit tests), and tests/e2e/i18n-cookie-persistence.spec.ts (e2e coverage of the persistence + sharing-semantics scenarios).