feat(web,settings): experiments stored on the account, read tolerantly both ways (experimental 1/3)

Merged
DA
6d ago
+9536-19

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/settings gains EXPERIMENT and EXPERIMENTS, with Factory as the first entry (EXPERIMENT.FACTORY = "factory").
  • The reader. experimentChoicesFrom is 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. accountPreferencesFromWire refuses 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.experiments is a new nullable jsonb column (migration 20261015001304_account_preference_experiments, one ADD 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/preferences answers 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:affected exit 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_at untouched, 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.ts and hosted-account-store.test.ts still pass.

Invariants

  • ./scripts/check.sh green. (CI)
  • JSON Schema goldens unchanged (LUKE_UPDATE_FIXTURES not 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 as type assertion outside the allowlisted files. (One as WireBoundaryInput in the new endpoint test's harness, the same line the plan-template test uses for JSON.parse.)
  • No Effect.runPromise/runSync/runFork outside 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.md sentence 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.md unchanged. The experiments a person turned on are their own stored setting, beside the voice.
  • Package package.json deps match. (@luke/database already reaches @luke/settings and @luke/wire.)
  • Barrel/door rule. experiments.ts imports only effect and @luke/wire types.

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