==> read the decisions
ADR-0007: Preferences live in the control database and resolve in three zones
Status: Accepted. Its transport is superseded by ADR-0013 (2026-10-14): one cs-prefs cookie, resolved on the server, no pre-paint script; the store and the rules here stand.
Context
Section titled “Context”Three preferences exist: the identity colour (the hemospectrum theme), the mode (light or dark), and the language. They affect everything the platform renders - the platform's own chrome, and every project's UI - so they are platform state, not project state.
Where they lived was never decided properly. In practice: cs-lang was in localStorage and in a
cookie, because the app's server needed it to render a correct first byte; cs-mode and cs-color
were in localStorage only, because the server did not read them. There was no durable copy at all,
so the preferences were per-browser and per-origin, and the app and the homepage could disagree about
them. There were also two implementations of the same resolution ladder - one inlined in
site/index.html, one across app/src/components/prefs.tsx and app/src/lib/lang.ts.
The surfaces that render a preference have different capabilities, and that is the thing that actually determines the design:
zone 1 the homepage static HTML on the CDN. No server, no logic, anonymous.zone 2 the app, before login server-rendered. A server is present, so headers are readable.zone 3 the app, after login server-rendered, and a session exists, so the database is reachable on the user's behalf.Decision
Section titled “Decision”The control database is the durable store. Three typed columns on a user_settings row keyed by
user - not a key/value table: the three values have different types and different valid sets, and a
K/V table would buy a join and lose validation. It belongs in the control database because it is
platform state about a user, in the same category as sessions, and it keeps that file free of
tenant data.
NULL is not the default. All three columns are nullable, so "nobody has chosen" is
representable and distinct from "chosen the value that happens to be the default". Without this, the
first page load that resolves a preference would write the resolved value and permanently pin the
user to whatever their browser guessed that day, with no way back to automatic.
The mode is light or dark and nothing else. There is no stored system. An unset mode resolves to
prefers-color-scheme in CSS, which costs no JavaScript and is not a value anything has to interpret
or write. The consequence is accepted deliberately: the control is a two-state toggle, so the first
click settles the question permanently and there is no route back to following the operating system.
That is the right trade for a toggle rather than a three-state cycle -- but it is a one-way door, and
it should be a decision rather than a surprise.
The cookie is the pre-paint transport, and localStorage is dropped for preferences. The cookie is
a strict superset of what localStorage offers here: client JavaScript reads it synchronously, exactly
as it would read localStorage, so there is no difference in what can be painted before first paint;
but a server can also read it, and being scoped to .clusterstuck.sh it is shared between the
homepage and the app, which a per-origin localStorage is not. One value stays consistent across both
surfaces instead of being chosen twice.
The presence of the cookie means "a human chose this". Nothing derived is ever written to it. This
is what carries the NULL distinction on the client side: an absent cookie means derive, a present
one means respect the choice and stop guessing.
One ladder, three zones. The order is the same everywhere and only the environment differs:
explicit choice (cookie) -> derived -> defaultwhere derived on the server is Accept-Language (already implemented, q-values honoured), and on
the client is navigator.languages, with mode falling back to prefers-color-scheme.
Reconciliation is one-directional per case, so there is no conflict to resolve. The database wins whenever it holds a value, and the cookie is rewritten to match (adopt). The cookie seeds the database only when no row exists at all (seed). Since only zone 3 can write the database, the database has exactly one writer, and no timestamps or vector clocks are needed to decide anything.
Zone 3 does not block on the database, unless it has no cached answer. With a cookie present the page renders from it immediately and reconciles asynchronously, so no page render depends on the API being up. With no cookie and a session - a fresh device - there is nothing to render from, so it fetches once before rendering rather than showing the default theme and correcting it.
Consequences
Section titled “Consequences”- The app can render the theme and the mode as attributes on the first byte, because the server now
has the values. That retires
boot.jsfrom painting them, which was previously impossible: with the server rendering defaults and the boot script rendering the visitor's real choice, React re-applied its own props and stripped the script's, and the page ended on the wrong hue. With the server rendering the correct value the two agree, the fight disappears, and the boot script shrinks to a change handler. Only zone 1 still needs a paint-time client script, because a static file cannot render server-side. - The derived mode case costs no JavaScript at all in any zone, since
prefers-color-schemeis expressible in CSS. - Zone 1 can never write the database, structurally. The session cookie is deliberately host-only
to
app.so that a static asset server never sees a token; therefore the anonymous homepage has no credential to present toapi.. A preference changed there is local and will be overwritten by the database at the next authenticated visit. That is the correct outcome - a cache does not author state - but it is a real behaviour and the homepage's switcher should not imply otherwise. - The preferences cookies ride every request to
clusterstuck.sh, including tocdn.. They are not credentials, so this is harmless, but the asymmetry with the session cookie is deliberate and must stay deliberate. - The two existing implementations of the ladder collapse into one resolver that takes the environment's capabilities as input.
- A project never owns a preference. It reads the resolved values through the platform - theme and
mode arrive as CSS custom properties, language as
html[lang]and a snapshot on the page - and a project's backend sees them as read-onlyctx.settingsso it can localise its own output. A project may ask the platform to change a preference; it may not set one.
Alternatives rejected
Section titled “Alternatives rejected”localStorageas the store. Per-origin, so the homepage and the app would each hold their own answer, and invisible to the server, so zone 2 and 3 could not render the right first byte.- Dropping the cookie instead. That regresses the app to rendering the default language and
swapping after hydration - the exact flash the cookie was introduced to remove - and discards the
Accept-Languagenegotiation already implemented and verified against q-value ordering. - Storing preferences in the JWT. The token is minted at login, so a preference changed afterwards would be stale until the next login. Mutable state does not belong in a credential.
- Widening the session cookie to
.clusterstuck.sh. Rejected when it was raised: it would put a token in every request the CDN receives. - Per-project language. A page cannot be half one language: the navbar is platform chrome.