feat(web,settings): experiments stored on the account, read tolerantly both ways (experimental 1/3)
Merged
DA
6d ago+9536+9536+0123456789012345678901234567890123456789-19-19-01234567890123456789
Summary
Where a developer's choice of experiments lives, and how it travels. This is PR 1 of Settings › Experimental (docs/proposals/experimental-settings.md, decided by Dean on 2026-10-14).
- The registry.
@luke/settingsgainsEXPERIMENTandEXPERIMENTS, with Factory as the first entry (EXPERIMENT.FACTORY = "factory"). - The reader.
experimentChoicesFromis used by both the store and the wire. It keeps every known experiment's on or off, passes over any other key, and refuses only a value that is not a record. An older or newer build's experiment, or a graduated one, therefore never costs a Mac the choices it knows.accountPreferencesFromWirerefuses a whole snapshot over one unknown field, which is why experiments get their own part rather than joining the voice's. - The column.
account_preference.experimentsis a new nullablejsonbcolumn (migration20261015001304_account_preference_experiments, oneADD COLUMN IF NOT EXISTS, hardened as the db-migrations skill asks, because the first preview deploy had applied an earlier draft of it). - The store. A write's keys are merged over what stood (
coalesce(experiments, '{}') || patch). A Mac that knows fewer experiments never erases another's choice, and the write never moves the preferences'updated_at, which a Mac reads as a snapshot to take. - The endpoint.
/api/account/preferencesanswers a third part,experiments({}until the account chooses), and takes it as a patch on PUT. An older desktop's readers ignore the extra key.
The desktop (cache, sync, write, the hook, the gates) is the next PR. The page comes after it.
Evidence
- Platform-independent checks:
pnpm check:affectedexit 0. - New tests:
packages/settings/src/experiments.test.ts: 3 tests.packages/database/src/models/__tests__/account-store.test.ts: 4 tests, PGlite. Covers the per-key merge, the other parts' writes leaving experiments standing, an unknown stored key reading as nothing,updated_atuntouched, and one account unable to read another's choices.apps/web/tests/account-experiments.test.ts: 3 tests, through the account group. Covers the GET default, merge and other parts untouched, unknown key passed over, non-record refused, and the bearer's own account only.
apps/web/tests/account-plan-template.test.tsandhosted-account-store.test.tsstill pass.
Invariants
-
./scripts/check.shgreen. (CI) - JSON Schema goldens unchanged (
LUKE_UPDATE_FIXTURESnot run). - Every behavior change has, in this PR, the test that fails without it; no change-detector test added.
- Gateway envelope goldens unchanged.
- No new
astype assertion outside the allowlisted files. (Oneas WireBoundaryInputin the new endpoint test's harness, the same line the plan-template test uses forJSON.parse.) - No
Effect.runPromise/runSync/runForkoutside the runtime edges. - No new
setTimeout/setInterval/new Promise/AbortController/fs.watch. - No Effect, Stream, or Layer written as a statement and dropped.
- Test count delta: +10 (listed above).
- Each hand-rolled file the PR replaces is deleted. (None.)
- Every
CLAUDE.md/AGENTS.mdsentence naming a changed part is edited.apps/web/server/README.md's snapshot paragraph now names three parts. No AGENTS.md sentence names the snapshot. -
PRIVACY.mdunchanged. The experiments a person turned on are their own stored setting, beside the voice. - Package
package.jsondeps match. (@luke/databasealready reaches@luke/settingsand@luke/wire.) - Barrel/door rule.
experiments.tsimports onlyeffectand@luke/wiretypes.
Notes
- Blockers or follow-up verification: None. The web half deploys from main on merge, before any desktop build asks for the part.
🤖 Generated with Claude Code