@variantlab/core 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,442 @@
1
+ # Config format (`experiments.json`)
2
+
3
+ The canonical specification for the `experiments.json` file. Also published as a JSON Schema at [`experiments.schema.json`](../../experiments.schema.json) for IDE validation.
4
+
5
+ ## Table of contents
6
+
7
+ - [File structure](#file-structure)
8
+ - [Top-level fields](#top-level-fields)
9
+ - [Experiment fields](#experiment-fields)
10
+ - [Variant fields](#variant-fields)
11
+ - [Targeting fields](#targeting-fields)
12
+ - [Rollback fields](#rollback-fields)
13
+ - [Validation rules](#validation-rules)
14
+ - [Examples](#examples)
15
+ - [Forward compatibility](#forward-compatibility)
16
+
17
+ ---
18
+
19
+ ## File structure
20
+
21
+ ```json
22
+ {
23
+ "$schema": "https://variantlab.dev/schemas/experiments.schema.json",
24
+ "version": 1,
25
+ "signature": "base64url-hmac-optional",
26
+ "enabled": true,
27
+ "experiments": [
28
+ {
29
+ "id": "example",
30
+ "name": "Example experiment",
31
+ "variants": [
32
+ { "id": "a" },
33
+ { "id": "b" }
34
+ ],
35
+ "default": "a"
36
+ }
37
+ ]
38
+ }
39
+ ```
40
+
41
+ ---
42
+
43
+ ## Top-level fields
44
+
45
+ | Field | Type | Required | Description |
46
+ |---|---|:-:|---|
47
+ | `$schema` | string | No | JSON Schema reference for IDE support. Ignored by the engine. |
48
+ | `version` | integer | Yes | Schema version. Must be `1`. The engine rejects unknown versions. |
49
+ | `signature` | string | No | Base64url-encoded HMAC-SHA256 of the canonical form of `experiments`. Verified via Web Crypto API when an `hmacKey` is configured. |
50
+ | `enabled` | boolean | No | Global kill switch. When `false`, all experiments return their default variant. Defaults to `true`. |
51
+ | `experiments` | array | Yes | Array of experiment definitions. Max 1000 entries. |
52
+
53
+ ### Why `version`?
54
+
55
+ The schema will evolve. We want forward-compatible configs that can upgrade in memory (minor) and be rejected cleanly (major). Currently version 1.
56
+
57
+ ### Why `signature`?
58
+
59
+ Optional HMAC enables tamper detection on remote configs. See [`docs/features/hmac-signing.md`](../features/hmac-signing.md).
60
+
61
+ ### Why `enabled`?
62
+
63
+ Kill switch for fast incident response. If a bad config ships, users can flip this flag in a new config and push to disable everything instantly without a code release.
64
+
65
+ ---
66
+
67
+ ## Experiment fields
68
+
69
+ | Field | Type | Required | Default | Description |
70
+ |---|---|:-:|---|---|
71
+ | `id` | string | Yes | — | Unique identifier. `/^[a-z0-9][a-z0-9-]{0,63}$/` |
72
+ | `name` | string | Yes | — | Human-readable. Max 128 chars. |
73
+ | `description` | string | No | — | Shown in debug overlay. Max 512 chars. |
74
+ | `type` | enum | No | `"render"` | `"render"` for component swaps, `"value"` for returned values. |
75
+ | `variants` | array | Yes | — | At least 2, at most 100. |
76
+ | `default` | string | Yes | — | Must match one of `variants[].id`. |
77
+ | `routes` | array | No | — | Glob patterns. Max 100. |
78
+ | `targeting` | object | No | — | Targeting predicate. |
79
+ | `assignment` | enum | No | `"default"` | Strategy: `default | random | sticky-hash | weighted`. |
80
+ | `split` | object | No | — | Traffic split for `weighted` strategy. |
81
+ | `mutex` | string | No | — | Mutual exclusion group. |
82
+ | `rollback` | object | No | — | Crash-rollback configuration. |
83
+ | `status` | enum | No | `"active"` | `draft | active | archived`. |
84
+ | `startDate` | ISO 8601 | No | — | Inactive before this. |
85
+ | `endDate` | ISO 8601 | No | — | Inactive after this. |
86
+ | `owner` | string | No | — | Free text. Max 128 chars. |
87
+ | `overridable` | boolean | No | `false` | Whether deep link overrides are accepted. |
88
+
89
+ ### `id`
90
+
91
+ Case-sensitive, lowercase. Allowed characters: `a-z`, `0-9`, `-`. Max 64 characters. Must not start with a hyphen. Examples:
92
+
93
+ - `cta-copy` ✅
94
+ - `news-card-layout` ✅
95
+ - `checkout-v2` ✅
96
+ - `CTA_copy` ❌ (uppercase + underscore)
97
+ - `-leading-dash` ❌
98
+
99
+ ### `type`
100
+
101
+ - `"render"`: designed for `<Variant>` component-swap usage. Variants don't need a `value`.
102
+ - `"value"`: designed for `useVariantValue` usage. Each variant has a `value` field.
103
+
104
+ Mixing is fine; you can use a `render`-type experiment with `useVariant` to read the variant ID as a string.
105
+
106
+ ### `default`
107
+
108
+ Required. Must reference a valid variant ID. Used when:
109
+
110
+ - Targeting fails
111
+ - Kill switch is on
112
+ - `startDate` is in the future or `endDate` is in the past
113
+ - Engine is in fail-open mode and an error occurs
114
+ - Deep link override is not allowed
115
+ - Crash rollback has triggered
116
+
117
+ ### `routes`
118
+
119
+ Glob patterns matching current route/pathname. Used both for targeting and for debug overlay filtering.
120
+
121
+ Supported patterns:
122
+
123
+ - Exact: `/`, `/about`
124
+ - Wildcard segment: `/blog/*`
125
+ - Wildcard deep: `/docs/**`
126
+ - Parameter: `/user/:id`
127
+ - Trailing-slash insensitive
128
+
129
+ Routes are matched against `VariantContext.route` at evaluation time. Adapters auto-populate this from the router:
130
+
131
+ - `@variantlab/react-native` — from Expo Router `usePathname` or React Navigation
132
+ - `@variantlab/next` — from `useRouter().pathname` or `usePathname`
133
+ - `@variantlab/remix` — from `useLocation().pathname`
134
+ - etc.
135
+
136
+ ### `assignment`
137
+
138
+ - `"default"` — always return the default variant. Useful for pre-launch experiments.
139
+ - `"random"` — uniform random across variants, assigned once per user and cached.
140
+ - `"sticky-hash"` — deterministic hash of `(userId, experimentId)` mapped to a variant. Stable across devices for the same `userId`.
141
+ - `"weighted"` — traffic split via `split` field. Uses sticky-hash for determinism.
142
+
143
+ ### `split`
144
+
145
+ Required when `assignment: "weighted"`. Object mapping variant IDs to integer percentages summing to 100.
146
+
147
+ ```json
148
+ "split": {
149
+ "control": 50,
150
+ "treatment-a": 25,
151
+ "treatment-b": 25
152
+ }
153
+ ```
154
+
155
+ ### `mutex`
156
+
157
+ Mutual exclusion group. Experiments with the same `mutex` cannot co-run on the same user. When two mutex'd experiments both target a user, the engine picks one by stable hash and excludes the others.
158
+
159
+ Use case: two competing card-layout experiments shouldn't both fire on the same session.
160
+
161
+ ### `rollback`
162
+
163
+ See [rollback fields](#rollback-fields) below and [`docs/features/crash-rollback.md`](../features/crash-rollback.md).
164
+
165
+ ### `status`
166
+
167
+ - `"draft"` — visible in debug overlay (with a draft badge), returns default in production
168
+ - `"active"` — normal operation
169
+ - `"archived"` — hidden from debug overlay, returns default
170
+
171
+ ### `startDate` / `endDate`
172
+
173
+ ISO 8601 timestamps. Inclusive start, exclusive end. Useful for time-boxed rollouts.
174
+
175
+ ### `owner`
176
+
177
+ Free-text field for tracking who owns the experiment. Not used by the engine. Shown in debug overlay.
178
+
179
+ ### `overridable`
180
+
181
+ Whether deep links can override this experiment. Default `false` for safety. See [`docs/features/qr-sharing.md`](../features/qr-sharing.md).
182
+
183
+ ---
184
+
185
+ ## Variant fields
186
+
187
+ | Field | Type | Required | Description |
188
+ |---|---|:-:|---|
189
+ | `id` | string | Yes | Unique within the experiment. Same regex as experiment ID. |
190
+ | `label` | string | No | Human-readable. Shown in debug overlay. Max 128 chars. |
191
+ | `description` | string | No | Shown in debug overlay. Max 512 chars. |
192
+ | `value` | any | No | For `type: "value"` experiments, the value returned by `useVariantValue`. |
193
+
194
+ ### `value`
195
+
196
+ Any JSON-serializable value. Strings, numbers, booleans, arrays, and objects are all supported. Type safety on the JS/TS side comes via codegen or explicit generic arguments.
197
+
198
+ ---
199
+
200
+ ## Targeting fields
201
+
202
+ All fields optional. **All specified fields must match** for a user to be eligible. An empty `targeting` object matches all users.
203
+
204
+ | Field | Type | Description |
205
+ |---|---|---|
206
+ | `platform` | `("ios" \| "android" \| "web" \| "node")[]` | Match if `context.platform` is in the list. |
207
+ | `appVersion` | string | Semver range. Match if `context.appVersion` satisfies. |
208
+ | `locale` | string[] | IETF language tags. Exact match or prefix match. |
209
+ | `screenSize` | `("small" \| "medium" \| "large")[]` | Match `context.screenSize`. |
210
+ | `routes` | string[] | Glob patterns. Match if `context.route` matches any. |
211
+ | `userId` | string[] \| hash object | Explicit user list or hash bucket. |
212
+ | `attributes` | object | Exact-match predicate on `context.attributes`. |
213
+
214
+ ### Screen-size buckets
215
+
216
+ Adapter packages auto-derive the bucket from screen dimensions:
217
+
218
+ - `small`: max(width, height) < 700 px
219
+ - `medium`: 700 ≤ max(width, height) < 1200 px
220
+ - `large`: max(width, height) ≥ 1200 px
221
+
222
+ These thresholds are configurable at engine-creation time but have good defaults.
223
+
224
+ ### App version matching
225
+
226
+ Supports standard semver range syntax:
227
+
228
+ - `>=1.2.0`
229
+ - `^1.2.0` (>= 1.2.0 < 2.0.0)
230
+ - `~1.2.0` (>= 1.2.0 < 1.3.0)
231
+ - `1.2.0 - 2.0.0` (range)
232
+ - `>=1.0.0 <2.0.0 || >=3.0.0` (compound)
233
+
234
+ Parsed by our hand-rolled semver matcher. See [`docs/research/bundle-size-analysis.md`](../research/bundle-size-analysis.md).
235
+
236
+ ### User ID matching
237
+
238
+ Two modes:
239
+
240
+ 1. **Explicit list**: `"userId": ["alice", "bob", "charlie"]`
241
+ 2. **Hash bucket**: `"userId": { "hash": "sha256", "mod": 10 }` — match if `sha256(userId) % 100 < mod` (10% of users)
242
+
243
+ ### Attributes matching
244
+
245
+ Exact-match predicates on arbitrary user attributes:
246
+
247
+ ```json
248
+ "targeting": {
249
+ "attributes": {
250
+ "plan": "premium",
251
+ "region": "us-west",
252
+ "betaOptIn": true
253
+ }
254
+ }
255
+ ```
256
+
257
+ All specified attributes must match exactly. For complex predicates, use the `targeting.predicate` escape hatch available only in application code, not in JSON.
258
+
259
+ ---
260
+
261
+ ## Rollback fields
262
+
263
+ | Field | Type | Required | Default | Description |
264
+ |---|---|:-:|---|---|
265
+ | `threshold` | integer | Yes | `3` | Crashes that trigger rollback. 1-100. |
266
+ | `window` | integer | Yes | `60000` | Time window in ms. 1000-3600000. |
267
+ | `persistent` | boolean | No | `false` | Persist rollback across sessions. |
268
+
269
+ When enabled, if a variant crashes `threshold` times within `window` milliseconds, the engine:
270
+
271
+ 1. Clears the user's assignment for that experiment
272
+ 2. Forces the `default` variant
273
+ 3. Emits an `onRollback` event
274
+ 4. If `persistent`, stores the rollback in Storage
275
+
276
+ See [`docs/features/crash-rollback.md`](../features/crash-rollback.md).
277
+
278
+ ---
279
+
280
+ ## Validation rules
281
+
282
+ The engine validates configs at load time and rejects:
283
+
284
+ - **Unknown version** (`version !== 1`)
285
+ - **Config larger than 1 MB**
286
+ - **Duplicate experiment IDs**
287
+ - **Duplicate variant IDs within an experiment**
288
+ - **`default` that doesn't match any variant**
289
+ - **`split` sum != 100** when assignment is `weighted`
290
+ - **Invalid route globs** (unsupported patterns)
291
+ - **Invalid semver ranges**
292
+ - **Targeting nesting deeper than 10 levels**
293
+ - **Invalid ISO 8601 timestamps**
294
+ - **Reserved keys** (`__proto__`, `constructor`, `prototype`)
295
+
296
+ Errors are collected and thrown as a `ConfigValidationError` with an `issues` array.
297
+
298
+ In fail-open mode (default), the engine logs the error and falls back to returning defaults. In fail-closed mode, it throws.
299
+
300
+ ---
301
+
302
+ ## Examples
303
+
304
+ ### Simple value experiment
305
+
306
+ ```json
307
+ {
308
+ "version": 1,
309
+ "experiments": [
310
+ {
311
+ "id": "cta-copy",
312
+ "name": "CTA button copy",
313
+ "type": "value",
314
+ "default": "buy-now",
315
+ "variants": [
316
+ { "id": "buy-now", "value": "Buy now" },
317
+ { "id": "get-started", "value": "Get started" },
318
+ { "id": "try-free", "value": "Try it free" }
319
+ ]
320
+ }
321
+ ]
322
+ }
323
+ ```
324
+
325
+ ### Render experiment with route scope
326
+
327
+ ```json
328
+ {
329
+ "version": 1,
330
+ "experiments": [
331
+ {
332
+ "id": "news-card-layout",
333
+ "name": "News card layout",
334
+ "routes": ["/", "/feed"],
335
+ "targeting": { "screenSize": ["small"] },
336
+ "default": "responsive",
337
+ "variants": [
338
+ { "id": "responsive", "label": "Responsive image" },
339
+ { "id": "scale-to-fit", "label": "Scale to fit" },
340
+ { "id": "pip-thumbnail", "label": "PIP thumbnail" }
341
+ ]
342
+ }
343
+ ]
344
+ }
345
+ ```
346
+
347
+ ### Weighted rollout with rollback
348
+
349
+ ```json
350
+ {
351
+ "version": 1,
352
+ "experiments": [
353
+ {
354
+ "id": "new-checkout",
355
+ "name": "New checkout flow",
356
+ "assignment": "weighted",
357
+ "split": { "control": 90, "new": 10 },
358
+ "default": "control",
359
+ "variants": [
360
+ { "id": "control" },
361
+ { "id": "new" }
362
+ ],
363
+ "rollback": {
364
+ "threshold": 5,
365
+ "window": 120000,
366
+ "persistent": true
367
+ }
368
+ }
369
+ ]
370
+ }
371
+ ```
372
+
373
+ ### Time-boxed experiment
374
+
375
+ ```json
376
+ {
377
+ "version": 1,
378
+ "experiments": [
379
+ {
380
+ "id": "black-friday-banner",
381
+ "name": "Black Friday banner",
382
+ "type": "render",
383
+ "startDate": "2026-11-24T00:00:00Z",
384
+ "endDate": "2026-12-01T00:00:00Z",
385
+ "default": "hidden",
386
+ "variants": [
387
+ { "id": "hidden" },
388
+ { "id": "shown" }
389
+ ]
390
+ }
391
+ ]
392
+ }
393
+ ```
394
+
395
+ ### Targeted beta
396
+
397
+ ```json
398
+ {
399
+ "version": 1,
400
+ "experiments": [
401
+ {
402
+ "id": "ai-assistant",
403
+ "name": "AI assistant beta",
404
+ "targeting": {
405
+ "platform": ["ios", "android"],
406
+ "appVersion": ">=2.0.0",
407
+ "attributes": { "betaOptIn": true }
408
+ },
409
+ "default": "disabled",
410
+ "variants": [
411
+ { "id": "disabled" },
412
+ { "id": "enabled" }
413
+ ]
414
+ }
415
+ ]
416
+ }
417
+ ```
418
+
419
+ ---
420
+
421
+ ## Forward compatibility
422
+
423
+ ### Minor version updates
424
+
425
+ If we add new optional fields in a future release, old configs still work. The engine ignores unknown fields that are backward-compatible by design.
426
+
427
+ ### Major version updates
428
+
429
+ A breaking change increments the `version` field. The engine refuses to load configs of a newer major version and logs a clear error pointing to the migration guide.
430
+
431
+ ### Migration strategy
432
+
433
+ We ship a `variantlab migrate` CLI command to upgrade configs between major versions. Migration is always one-way (v1 → v2); we don't support downgrades.
434
+
435
+ ---
436
+
437
+ ## See also
438
+
439
+ - [`experiments.schema.json`](../../experiments.schema.json) — machine-readable JSON Schema
440
+ - [`API.md`](../../API.md) — TypeScript interfaces matching this format
441
+ - [`docs/features/codegen.md`](../features/codegen.md) — how configs become types
442
+ - [`docs/features/targeting.md`](../features/targeting.md) — targeting predicate semantics
@@ -0,0 +1,212 @@
1
+ # Design principles
2
+
3
+ The 8 principles that govern every design decision in variantlab. When we disagree about a change, we refer back to this document.
4
+
5
+ ## Table of contents
6
+
7
+ 1. [Framework-agnostic core, thin adapters](#1-framework-agnostic-core-thin-adapters)
8
+ 2. [Zero runtime dependencies](#2-zero-runtime-dependencies)
9
+ 3. [ESM-first, tree-shakeable, edge-compatible](#3-esm-first-tree-shakeable-edge-compatible)
10
+ 4. [Security by construction](#4-security-by-construction)
11
+ 5. [Declarative JSON as the contract](#5-declarative-json-as-the-contract)
12
+ 6. [SSR correct everywhere](#6-ssr-correct-everywhere)
13
+ 7. [Privacy by default](#7-privacy-by-default)
14
+ 8. [Docs-first development](#8-docs-first-development)
15
+
16
+ ---
17
+
18
+ ## 1. Framework-agnostic core, thin adapters
19
+
20
+ **Principle**: The engine runs in any ECMAScript environment. Every framework binding is a thin wrapper.
21
+
22
+ **Why**: Frameworks change. React's concurrent mode, Vue's composition API, Svelte's runes, Solid's signals — each framework has its own way of expressing reactivity. If we tie the engine to one of them, we're locked in.
23
+
24
+ **Consequence**:
25
+ - `@variantlab/core` never imports `react`, `vue`, `svelte`, `solid`, or any framework
26
+ - The engine exposes a synchronous `subscribe(listener)` API and framework adapters bridge to their native reactivity
27
+ - Each adapter is < 200 LOC
28
+ - Adding a new framework costs us days, not months
29
+
30
+ **Test**: Can we write the core in pure TypeScript with only the TypeScript standard library? Yes. Is there any framework-specific code in `@variantlab/core`? No.
31
+
32
+ ---
33
+
34
+ ## 2. Zero runtime dependencies
35
+
36
+ **Principle**: `@variantlab/core` has zero runtime dependencies. Adapter packages have exactly one: `@variantlab/core`.
37
+
38
+ **Why**: Every runtime dependency is a supply-chain attack vector, a potential version conflict, a source of bundle bloat, and a transitive trust decision. The `event-stream` incident (2018), the `ua-parser-js` hijack (2021), the `xz-utils` backdoor (2024) — every major supply chain attack starts with an innocuous dependency.
39
+
40
+ **Consequence**:
41
+ - We write our own schema validator (400 bytes vs zod's 12 KB)
42
+ - We write our own semver matcher (250 bytes vs `semver`'s 6 KB)
43
+ - We write our own route glob matcher (150 bytes vs `minimatch`'s 4 KB)
44
+ - We write our own hash function (80 bytes vs `murmurhash`'s 500 bytes)
45
+ - We use Web Crypto API for HMAC, not a polyfill
46
+ - We reject every PR that adds a runtime dep without 2 maintainer approvals + discussion
47
+
48
+ **Test**: Does `npm ls --prod` in `@variantlab/core` show zero entries? Yes. Enforced by CI.
49
+
50
+ **Tradeoff**: We spend engineering time writing hand-rolled replacements for well-tested libraries. This is intentional. The cost is upfront; the benefit is compounding.
51
+
52
+ ---
53
+
54
+ ## 3. ESM-first, tree-shakeable, edge-compatible
55
+
56
+ **Principle**: All packages ship ES modules with aggressive tree-shaking. Every package runs in every modern runtime — Node 18+, Deno, Bun, browsers, React Native Hermes, Cloudflare Workers, Vercel Edge, AWS Lambda@Edge.
57
+
58
+ **Why**: CommonJS is dying. ESM is the future. Edge runtimes are the future. Any library that doesn't support all of these is a library that will need a rewrite in 3 years.
59
+
60
+ **Consequence**:
61
+ - `"type": "module"` in all package.json files
62
+ - Dual ESM+CJS output only for maximum compatibility (CJS wrapped via tsup)
63
+ - `"sideEffects": false` so bundlers tree-shake aggressively
64
+ - Separate entry points per feature (`@variantlab/core`, `@variantlab/core/debug`, `@variantlab/core/crypto`)
65
+ - No Node built-ins in core (no `fs`, no `path`, no `process`)
66
+ - No browser built-ins in core (no `window`, no `document`, no `localStorage`)
67
+ - Only Web APIs: `crypto.subtle`, `Date`, `Math`, `Map`, `Set`
68
+
69
+ **Test**: Does `@variantlab/core` import from `node:*`? No. Does it reference `window`? No. Does it run in a Cloudflare Worker sandbox? Yes.
70
+
71
+ ---
72
+
73
+ ## 4. Security by construction
74
+
75
+ **Principle**: Security is a property of the design, not a feature we add later.
76
+
77
+ **Why**: Security features retrofitted onto a library are always worse than security features designed in. A library that assumes it will only see trusted input will have a CVE the first time it sees untrusted input.
78
+
79
+ **Consequence**:
80
+ - No `eval`, no `Function()`, no dynamic `import()` on config data
81
+ - Prototype pollution blocked at the schema validator level via `Object.create(null)` and key allow-lists
82
+ - Constant-time HMAC verification via Web Crypto
83
+ - Hard limits on config size, nesting, and iteration counts
84
+ - Fail-open by default (return defaults on errors) with a fail-closed opt-in
85
+ - No global mutation (`window`, `globalThis`, module-level state)
86
+ - Config frozen after load via `Object.freeze`
87
+ - Zero-telemetry by default
88
+
89
+ **Test**: Does a crafted config with `{"__proto__": {"admin": true}}` compromise any object? No. Does a 10 MB config crash the engine? No, it's rejected at 1 MB. Does timing-attack HMAC verification reveal bytes? No, we use `crypto.subtle.verify`.
90
+
91
+ See [`SECURITY.md`](../../SECURITY.md) and [`docs/research/security-threats.md`](../research/security-threats.md).
92
+
93
+ ---
94
+
95
+ ## 5. Declarative JSON as the contract
96
+
97
+ **Principle**: `experiments.json` is the single source of truth. The engine's behavior is fully determined by the config plus the runtime context.
98
+
99
+ **Why**: JSON configs are:
100
+ - Version-controllable — diffs are readable in PRs
101
+ - Reviewable — a non-developer can read the config
102
+ - Tool-able — linters, codegen, IDE schema completion all work
103
+ - Portable — the same config works in every framework
104
+ - Safe — no code execution, no hidden behavior
105
+
106
+ **Consequence**:
107
+ - The config has a published JSON Schema (`experiments.schema.json`) for IDE validation
108
+ - The CLI provides `validate` to check configs in CI
109
+ - The CLI provides `generate` to codegen TypeScript types from configs
110
+ - Targeting predicates are data structures, not functions (except the escape hatch)
111
+ - Custom predicates (`targeting.predicate`) can only be supplied in application code, never in the JSON itself
112
+ - Configs are forward-compatible via a required `version` field; the engine refuses unknown versions
113
+
114
+ **Test**: Can a product manager edit `experiments.json` without reading any code? Yes. Can a QA engineer verify the config's validity without running the app? Yes (`variantlab validate`).
115
+
116
+ ---
117
+
118
+ ## 6. SSR correct everywhere
119
+
120
+ **Principle**: The engine is deterministic. Given the same config and context, it produces the same variant every time. This means no hydration mismatches, ever.
121
+
122
+ **Why**: A/B testing tools that cause hydration mismatches are broken. They force users to either render a placeholder (causing layout shift) or to defer all variant selection to the client (losing the point of SSR).
123
+
124
+ **Consequence**:
125
+ - No `Math.random()` in hot paths (even for `random` assignment — we use a seeded RNG keyed by userId)
126
+ - No `Date.now()` in hot paths (time-based targeting only at boundaries)
127
+ - Stable iteration order over config (sort by ID at load time)
128
+ - Cookie-based stickiness as the default hydration strategy
129
+ - Server helpers (`getVariantSSR`, `createVariantLabServer`) that run on any runtime
130
+ - Framework adapters handle the `<Provider initialVariants={...}>` hydration pattern
131
+
132
+ **Test**: Does rendering the same page 100 times produce identical HTML for a given user? Yes. Does the Next.js App Router test suite report zero hydration mismatches? Yes.
133
+
134
+ See [`docs/research/framework-ssr-quirks.md`](../research/framework-ssr-quirks.md).
135
+
136
+ ---
137
+
138
+ ## 7. Privacy by default
139
+
140
+ **Principle**: variantlab collects zero data about users, developers, or their apps. Every network call is explicit and opt-in.
141
+
142
+ **Why**: "Privacy opt-out" is a dark pattern. "Anonymous telemetry" is still data collection. GDPR and CCPA have real teeth. We believe a library should do exactly what the user asks and nothing more.
143
+
144
+ **Consequence**:
145
+ - No phone-home on import, initialization, or any event
146
+ - No analytics, no error tracking, no usage stats, no "anonymous ID"
147
+ - `Fetcher` is always user-provided — we ship a helper, not a default endpoint
148
+ - `Telemetry` is always user-provided — we ship an interface, not an implementation
149
+ - User IDs passed for sticky hashing are hashed client-side before any network call
150
+ - Debug overlay state is stored locally — QR sharing generates the QR on-device
151
+ - Every dependency is audited for phone-home behavior
152
+
153
+ **Test**: Run variantlab in a sandbox with no network access. Does it still work? Yes (except for user-provided remote configs). Does it try to open any connections? No.
154
+
155
+ **Promise to users**: If we ever add telemetry, it will be opt-in only, clearly documented, and the repo will be publicly forkable to a telemetry-free version under the same license.
156
+
157
+ ---
158
+
159
+ ## 8. Docs-first development
160
+
161
+ **Principle**: Every public API is specified in markdown before any code is written.
162
+
163
+ **Why**: Libraries that ship APIs first and documentation later almost always end up with inconsistencies, under-documented corners, and unclear intent. Libraries like TanStack Query, Zod, and tRPC all ship comprehensive docs *before* the corresponding code.
164
+
165
+ **Consequence**:
166
+ - `API.md` is authoritative — any PR that changes public APIs must update this file first
167
+ - `ARCHITECTURE.md` describes the runtime data flow before we implement it
168
+ - `SECURITY.md` describes the threat model before we design mitigations
169
+ - Every feature has a `docs/features/<name>.md` spec before implementation starts
170
+ - Every phase has a `docs/phases/phase-N-*.md` plan with exit criteria
171
+ - We run a design review on each doc with at least 2 reviewers before locking it
172
+
173
+ **Test**: Can a contributor read the docs and predict exactly what the code does? Yes. Does the test suite match the documented behavior? Yes.
174
+
175
+ ---
176
+
177
+ ## How these principles interact
178
+
179
+ The principles sometimes conflict. When they do:
180
+
181
+ - **Security always wins** over convenience
182
+ - **Privacy always wins** over features
183
+ - **Zero-dependency wins** over minor DX improvements
184
+ - **SSR correctness wins** over bundle size
185
+ - **Framework-agnostic core wins** over per-adapter ergonomics
186
+
187
+ We document every tradeoff in the relevant feature spec.
188
+
189
+ ---
190
+
191
+ ## Anti-principles (what we refuse to be)
192
+
193
+ 1. **Not a SaaS.** We will never host a dashboard. We will ship reference self-host templates.
194
+ 2. **Not a data collector.** We will never send data we don't absolutely need.
195
+ 3. **Not a kitchen sink.** We will say no to features that don't earn their bundle-size cost.
196
+ 4. **Not a plugin platform.** We keep the core small and let users compose.
197
+ 5. **Not one-framework-only.** We refuse to optimize for any single framework at the expense of others.
198
+ 6. **Not a dashboard.** Configs are files. Tools edit files. Git diffs them. We stay out of the editing UI business.
199
+ 7. **Not closed-source.** Everything is MIT, everything is public, forever.
200
+
201
+ ---
202
+
203
+ ## Review cadence
204
+
205
+ These principles are reviewed:
206
+
207
+ - **On every major release** (1.0, 2.0, etc.)
208
+ - **When we add a new framework adapter** (does the adapter respect principle 1?)
209
+ - **When we add a runtime dependency** (does this violate principle 2?)
210
+ - **When we add a feature that collects data** (does this violate principle 7?)
211
+
212
+ Changes to principles require a public discussion and 2 maintainer approvals.