@revturbine/sdk 0.9.0 → 0.9.2

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,651 @@
1
+ # Changelog
2
+
3
+ Release notes and breaking changes for the RevTurbine SDKs. `@revturbine/sdk` (npm), `revturbine`
4
+ (PyPI) and `revturbine` (crates.io) are **version-locked** — they always ship the
5
+ same version number, so a version here applies to all three unless an entry says
6
+ otherwise.
7
+
8
+ Verified for `0.8.1`: npm `0.8.1`, PyPI `0.8.1`, crates.io `0.8.1` (2026-09-14).
9
+
10
+ Every breaking change is recorded in the same shape, because the thing that cost
11
+ an integration the most time was not the change itself but not being able to
12
+ answer *"which version broke this, and which version made it loud?"*:
13
+
14
+ | field | what it answers |
15
+ |---|---|
16
+ | **What changed** | the old shape and the new one, concretely |
17
+ | **Landed in** | the version that introduced the change |
18
+ | **Fail-closed in** | the version where the old shape stopped being silently tolerated. Often the same version; when it is **not**, the gap is where integrations break quietly. |
19
+ | **Proving test** | the test in this repo that would fail if the behaviour regressed |
20
+
21
+ A change whose "Fail-closed in" is later than its "Landed in" had a window where
22
+ the old shape was accepted and did nothing. Those windows are the expensive ones.
23
+
24
+ > **What this file is not yet.** A CI guard fails any PR that changes a
25
+ > `@public`-tagged export signature without touching this file. That tagging is
26
+ > thin: as of `0.8.0` it covers **4** methods on `RevTurbineCustomerSdk`, which
27
+ > declares roughly **73**. `can()`, `getPlacementDecision()`, `identify()` and
28
+ > `dismiss()` are among the untagged, so a breaking change to one of them will
29
+ > not be caught automatically — it depends on whoever writes the PR. Entries here
30
+ > are reliable; the absence of an entry is not yet proof that nothing changed.
31
+
32
+ > **Note on 0.x.** These packages are pre-1.0, so breaking changes ship in the
33
+ > **minor** position (`0.6.0` → `0.7.0`), not the major. `npm`'s caret on a `0.x`
34
+ > range does **not** span minors, so `^0.7.0` will not silently pull `0.8.0`.
35
+
36
+ ---
37
+
38
+ ## 0.9.2
39
+
40
+ ### Public initializer user context
41
+
42
+ `await initRevTurbine({ user: { id, ...context }, ...options })` from
43
+ `@revturbine/sdk` or `@revturbine/sdk/headless` now passes `id` only as the
44
+ identity argument. Supported user context retains its existing behavior without
45
+ the spurious unrecognized-context-key warning for `id`; genuinely unknown keys
46
+ still warn. Public quick starts now show the awaited `SdkSession` and
47
+ `session.sdk.getBranding()`. The separate synchronous core initializer is unchanged.
48
+
49
+ Proving tests: `web-sdk/public-init.test.ts` and the installed public-package
50
+ runtime/type examples in `web-sdk/scripts/check-public-diagnostics.mjs`.
51
+
52
+ ## 0.9.1
53
+
54
+ ### Public changelog distribution
55
+
56
+ The maintained changelog is now included as `CHANGELOG.md` in the npm package
57
+ and mirrored at the root of the public SDK repository. The docs Reference
58
+ navigation links directly to that authoritative public file. The independent,
59
+ hidden docs copy has been retired. The pre-0.3.0 history gap below remains;
60
+ this release does not reconstruct it. No SDK runtime behavior changed.
61
+
62
+ ## 0.9.0
63
+
64
+ ### Placement eligibility follows current context after conversion
65
+
66
+ - **What changed** — Conversion remains an analytics event and no longer retires
67
+ or temporarily suppresses a placement. Legacy conversion-owned browser state
68
+ is ignored. Supply the updated UserContext after a plan change; Playbook
69
+ targeting determines whether the offer still applies.
70
+ - **What changed** — Fixed and Access Gate placements bypass dismissal, reminder
71
+ and bare-click cooldowns. Dismiss and snooze close the current display; the
72
+ next explicit trigger, refresh or mount evaluates again. This uses the resolved
73
+ placement category, including when another category renders inside a Fixed slot.
74
+ Other categories retain authored/default windows, and explicit system
75
+ suppression and entitlement denial remain effective.
76
+ - **Landed in** — `0.9.0` across JavaScript, Python and Rust.
77
+ - **Fail-closed in** — Not applicable; no input shape was removed. Rust callers
78
+ constructing `InteractionState` literals should supply the new optional
79
+ `explicit_suppressed_until` field or use `..Default::default()`.
80
+ - **Proving test** — `web-sdk/conversion-eligibility.test.ts`,
81
+ `web-sdk/placements/FixedSurfaceSlot.dismissal.test.tsx`, and the shared
82
+ `placement_category_interactions` parity fixture.
83
+
84
+ ## 0.8.9
85
+
86
+ ### JavaScript: development diagnostics survive package minification
87
+
88
+ - **What changed** — The SDK package preserves the environment check until the
89
+ consuming application builds it. Previously the minified package baked in
90
+ production mode, hiding the React initialization-failure banner, app-theme
91
+ override warning and legacy-theme warning even in development applications.
92
+ Production applications still suppress those development diagnostics;
93
+ `initStatus` and required initialization-error reporting remain available.
94
+ The existing fallback for browsers without `process` is preserved.
95
+ - **Landed in** — `0.8.9`.
96
+ - **Fail-closed in** — Not applicable; no decision or initialization policy changes.
97
+ - **Proving test** — `web-sdk/scripts/check-public-diagnostics.mjs` installs the
98
+ public tarball and runs Chromium against separate development, production
99
+ and environment-preserving consumer bundles. The PR release-build gate
100
+ retains the tarball, inventory, SHA-256 and observed diagnostics.
101
+
102
+ Python and Rust receive the matching version bump; their behavior is unchanged.
103
+
104
+ ## 0.8.8
105
+
106
+ Behavioural fixes in Python; a new decision surface in Rust.
107
+
108
+ ### Python: numeric coercion unified onto JS Number() semantics
109
+
110
+ - **What changed** — `parse_numberish`'s string branch used Python
111
+ `float()`, which accepts forms JS `Number()` rejects (underscore
112
+ separators: `"1_000"` scored 1000 here, NaN->0 in TypeScript — a
113
+ candidate-flipping divergence in every selection scorer) and rejects
114
+ forms JS accepts (`"0x10"` is 16). The one JS-semantics parser from the
115
+ plan-233 segments port is promoted to `helpers.js_number` and every
116
+ coercion site shares it. Ripple alignments: `Number("")` is 0 (the old
117
+ is-None assertion pinned the float() behaviour), a whitespace-only
118
+ `template_version` resolves to `"0"` exactly as TS does, and
119
+ `superseded_versions` no longer fabricates a `"0"` entry from an empty
120
+ string (mirrors TS's trim-then-filter).
121
+
122
+ ### Rust: the candidate-selection layer, ported
123
+
124
+ - **What changed** — `resolve_local_placement_from_candidates` plus the
125
+ full helper set (category buckets, plan-53 two-stage tier-3 urgency,
126
+ milestone supersession, category conflict suppression, server-order
127
+ dominance) now exists in Rust (`placements/selection.rs`). Until now the
128
+ Rust port selected by entry order only; competing-category selections
129
+ had no third side, which is why plan 234 TASK-15's fixture was deferred.
130
+ - **Proving test** — parity fixture `placement_selection_priority`
131
+ (10 calls: cross-tier dominance, both tier-3 stages including a
132
+ class-vs-proximity disagreement, retention/conversion tie, server_order
133
+ skipping conflict suppression, fixed_only, milestone supersession, both
134
+ REQ-5 coercion lanes, output_id tiebreak), byte-identical ts/py/rs on
135
+ its first regen, plus mirrored port-local suites in Python and Rust.
136
+
137
+ ## 0.8.7
138
+
139
+ No runtime behaviour change in any port; the release carries new parity
140
+ locks and one serde derive.
141
+
142
+ ### All ports: trial-status derivation and allocation scoping parity-locked
143
+
144
+ - **What changed** — two decision surfaces every runtime already evaluated
145
+ gained their first cross-language locks. `evaluateTrialStatus` (the
146
+ free/reverse trial-rule derivation): the TS golden vectors mirrored the
147
+ Python unit suite by hand-maintained convention, and Rust was never in
148
+ the mirror at all; the `trial_status_evaluation` fixture makes the
149
+ agreement a byte-checked property. `computeConsumedPercent` (the spec
150
+ 3.4 Pooling allocation-scoped grant selection): reachable from no
151
+ placement fixture because the static provider has no grants lane; the
152
+ `threshold_allocation_scoping` fixture drives every allocation label
153
+ against a hierarchy whose levels carry DIFFERENT counters.
154
+ - **A wire divergence caught on first regen** — the reverse-trial grants
155
+ set serialized three different ways (TS `JSON.stringify(Set)` = `{}`
156
+ with the contents silently lost; Python's canonicalizer dropped the key;
157
+ Rust emitted the honest list). The runners now own the wire form: a
158
+ sorted array on every side.
159
+ - **Fail-closed in** — no evaluator changed; `TrialEvaluation` gained a
160
+ `Serialize` derive in Rust (additive).
161
+ - **Proving test** — parity fixtures `trial_status_evaluation` (6 calls;
162
+ the usage-lane call was sharpened after the first cut put
163
+ `trial_limit_type` on the RULE and every side agreed on `time` - the
164
+ non-discriminating shape REQ-3 forbids; it rides the INSTANCE) and
165
+ `threshold_allocation_scoping` (9 calls).
166
+
167
+ ## 0.8.6
168
+
169
+ One capability lands in the two ports; TypeScript is unchanged and ships this
170
+ version for lockstep alone.
171
+
172
+ ### Python + Rust: targeting-state derivation ported (buildTargetingState)
173
+
174
+ - **What changed** — `LocalRuntime.build_targeting_state` raised
175
+ NotImplementedError in Python (a REQ-14 deferral) and had no Rust port at
176
+ all, so the derivation from a raw user context to the traits segment
177
+ evaluation consumes existed only in TypeScript: segment parity proved
178
+ "agrees given the same traits", never "agrees given the same user
179
+ context". Both ports now carry the pure derivation
180
+ (`core.user_context.build_targeting_state` / `user_context.rs`), byte-
181
+ locked against the TS canonical.
182
+ - **Also aligned red-first** — Python's dormant
183
+ `configured_plan_name_from_exported_config` (no callers until now) still
184
+ carried pre-plan-120 semantics: id matching, plan-object resolution, and
185
+ a `_handle` suffix fallback. Plans resolve by `unique_handle` alone; the
186
+ drift-pinning tests were corrected with the alignment.
187
+ - **Fail-closed in** — identity rules are shadow-proof by construction:
188
+ `plan_handle` is a reserved trait a `custom.plan_handle` can never
189
+ impersonate, and no identity means the reserved trait is REMOVED and the
190
+ effective plan omitted.
191
+ - **Proving test** — parity fixture `targeting_state_derivation` (6 calls:
192
+ identity precedence, shadow-proofing, plan-OBJECT fallback, the id that
193
+ must resolve nothing, the no-identity removal, override-wins usage with a
194
+ legacy bare-number entry), byte-identical ts/py/rs, plus the corrected
195
+ unit matrices in both ports.
196
+
197
+ ## 0.8.5
198
+
199
+ One behavioural alignment, Python only. TypeScript and Rust are unchanged and
200
+ ship this version for lockstep alone.
201
+
202
+ ### Python: category buckets and tier-3 urgency aligned to the plan-53 model
203
+
204
+ - **What changed** — this port's `category_bucket` carried the pre-plan-53
205
+ map for months (trial=3, retention=5 where the canonical folds trial into
206
+ priority tier 3 as bucket 2 and retention into the single discretionary
207
+ bucket 4); its docstring cited source lines that had moved AND changed
208
+ upstream. The map feeds the selection comparator, so a trial and a usage
209
+ placement competing for one surface ordered STRICTLY here (usage always
210
+ first) while the canonical ties them and applies the plan-53 two-stage
211
+ urgency model — which this port also lacked. `tier3_class` is now ported
212
+ and staged ahead of proximity in `resolve_local_placement_from_candidates`,
213
+ the map matches the canonical, and the system-caps exemption boundary moved
214
+ from the compensating `<= 3` to the canonical `<= 2` (the exempted category
215
+ set is unchanged).
216
+ - **Landed in** — `helpers.category_bucket` / `helpers.tier3_class`, the
217
+ comparator in `placement_decision.py`, and the caps exemption boundary.
218
+ - **Fail-closed in** — no gate changes; this is ordering. Exemptions and
219
+ discretionary classification are set-identical before and after.
220
+ - **Proving test** — `tests/placements/test_selection_tier3.py` (class
221
+ ordering with class-vs-proximity DISAGREEMENT cases; mutation-checked:
222
+ disabling the tier3 stage fails exactly the two stage-1 tests), plus the
223
+ bucket-map matrix in `test_helpers.py` whose old strict `usage < trial`
224
+ invariant was pinning the drift. The cross-language fixture for this
225
+ surface lands with the Rust selection-layer port (plan 234 TASK-8).
226
+
227
+ ## 0.8.4
228
+
229
+ One behavioural alignment, Python only. TypeScript and Rust are unchanged and
230
+ ship this version for lockstep alone.
231
+
232
+ ### Python: cap/cooldown maths consolidated onto the shared primitive
233
+
234
+ - **What changed** — `placement_decision.py` and `cap_enforcer.py` carried
235
+ their own copies of the cap-window and cooldown arithmetic that TypeScript
236
+ consolidated onto `evaluateCaps` (plan 233 TASK-15 / scaffold #351). Both
237
+ now delegate to the new `cap_rules.evaluate_caps` port. Reading the bodies
238
+ side by side surfaced two silent divergences, both aligned to the TS
239
+ canonical: Fixed / Access-Gate categories are now exempt from per-payload
240
+ caps in `check_placement_caps` (TS has exempted them since plan 167; this
241
+ port capped them), and a cap deny now persists the UNFILTERED state — the
242
+ old bodies trimmed `seen_at` to the tripping window (one comment claimed
243
+ "Mirrors TS"; TS does no such trim), silently shrinking future week/month
244
+ windows.
245
+ - **Landed in** — `core/placements/cap_rules.py` (the primitive),
246
+ `check_placement_caps` / `check_system_presentation_caps` /
247
+ `CapEnforcer.enforce` (the delegations).
248
+ - **Fail-closed in** — verdicts only ever tighten or stay: the exemption
249
+ affects categories that are user-prompted by design, and the state
250
+ alignment preserves more history, never less.
251
+ - **Proving test** — `tests/placements/test_cap_delegation.py`: a 12-case
252
+ characterization matrix green before AND after the refactor, the two
253
+ aligned behaviours run RED against the pre-refactor body, and a mutation
254
+ of the shared window arithmetic fails four tests across both entry points
255
+ (the shared maths is reached, not shadowed).
256
+
257
+ ## 0.8.3
258
+
259
+ One behavioural fix, Rust only. TypeScript and Python are unchanged and ship
260
+ this version for lockstep alone.
261
+
262
+ ### Rust: a confirmed conversion did not retire the placement
263
+
264
+ - **What changed** — the Rust port carried the full ImpressionHistory
265
+ machinery (`record_conversion`, `is_hidden_sync`, the retired cache) since
266
+ the port landed, but its placement resolver never consulted it. A user who
267
+ completed a placement's CTA — a confirmed conversion — kept seeing that
268
+ placement in this port alone, while TypeScript core
269
+ (`local-resolver.ts:610-612`, `:726`) and Python both hide it permanently.
270
+ This is the defect class plan 167's unshipped follow-up became as a
271
+ customer P1: paying customers shown the upsell they already bought.
272
+ - **Landed in** — the retirement gate is wired at both decision lanes: the
273
+ candidate filter (before the trigger gates, mirroring the TS order) and
274
+ the direct-lookup path, which now answers `placement_retired`, keyed by
275
+ the output's `rule_id` exactly as TS core and Python key it.
276
+ - **Fail-closed in** — the gate only ever hides; an empty or cold history
277
+ changes nothing, and a placement with no recorded conversion is untouched.
278
+ - **Proving test** — parity fixture `placement_conversion_retires`
279
+ (visible -> convert -> `placement_retired`, with an untouched control
280
+ staying visible, byte-identical across ts/py/rs; mutation-checked: making
281
+ the Rust gate inert diverges the snapshot) and
282
+ `server-rust/tests/local_runtime.rs`
283
+ (`a_confirmed_conversion_permanently_retires_the_placement`).
284
+
285
+ ## 0.8.2
286
+
287
+ One behavioural fix, Rust only. TypeScript and Python are unchanged and ship
288
+ this version for lockstep alone.
289
+
290
+ ### Rust: provider-path rule selection was source-order, not most-permissive
291
+
292
+ - **What changed** — on the provider path (`check_entitlement` with a rules
293
+ provider, the path a static config registers), the Rust port's §2.6.5
294
+ most-permissive selection scored each rule snapshot's root object instead
295
+ of its `fields` sub-object, where the adapter actually nests the score keys
296
+ (`limit_value`, `allowance`, `included_count`, `enabled`). Every rule
297
+ scored 0, so the deterministic tie-break — earliest in source order —
298
+ silently decided instead. Concretely: an entitlement carrying both a seat
299
+ rule (listed first) and a `usage_limit` rule at limit 2 returned **allowed**
300
+ at `used: 3` where TypeScript and Python return limited
301
+ (`usage_limit_reached`). Whether you were affected depends on rule order in
302
+ your Playbook, which is what made this silent. The ExportedConfig fallback
303
+ path (`derive_local_entitlement_from_configured_rules`) was NOT affected —
304
+ it passes the merged type-fields bag and scored correctly.
305
+ - **Landed in** — `0.8.2`.
306
+ - **Fail-closed in** — `0.8.2` (same release; no tolerated window).
307
+ - **Proving test** — parity fixture `entitlement_rule_seat_included_count`
308
+ (byte-locked ts ≡ py ≡ rs; diverged on exactly the shadowed-limit call until
309
+ the fix) and `most_permissive_scores_the_fields_sub_object_not_the_snapshot_root`
310
+ (Rust unit, mutation-checked). The pre-existing Rust unit test had authored
311
+ its score key at the snapshot root — a shape the adapter never emits — and
312
+ passed over the bug; it now uses the production shape.
313
+
314
+ ---
315
+
316
+ ## 0.8.1
317
+
318
+ One behavioural fix, Python and Rust only. The TypeScript SDK is unchanged and
319
+ ships this version for lockstep alone.
320
+
321
+ ### Python/Rust: cross-dimension segment AND no longer degrades to flat OR
322
+
323
+ - **What changed** — the config evaluator's segment→dimension lookup was keyed
324
+ by the segment's `id` in the Python and Rust ports, where the TypeScript
325
+ canonical keys it by `handle`. Rule `segment_ids` are handle-valued, so on
326
+ any Playbook whose segment ids differ from their handles — every real
327
+ export — the ports' lookups all missed, every rule segment collapsed into
328
+ the uncategorised bucket, and a rule spanning two dimensions (which must
329
+ require BOTH, intra-dimension OR + cross-dimension AND) matched on EITHER.
330
+ Concretely: an entitlement rule scoped to `emea` (region) AND `admins`
331
+ (role) **granted** a user who was only in `emea`. It now denies, matching
332
+ the TypeScript SDK. If your integration passes a non-empty user segment set
333
+ to `derive_local_entitlement_from_configured_rules` and relies on a
334
+ cross-dimension rule, users who matched only one dimension lose the grant
335
+ on upgrade — that is the fix working. The built-in runtime fallback path
336
+ passes an empty segment set and is unaffected.
337
+ - **Landed in** — `0.8.1`.
338
+ - **Fail-closed in** — `0.8.1` (same release; no tolerated window).
339
+ - **Proving test** — parity fixtures `entitlement_segment_dimensions` /
340
+ `entitlement_segment_no_dim_bucket` (byte-locked TS ≡ Python ≡ Rust; the
341
+ regen diverged on exactly the cross-dimension calls until the fix), plus
342
+ `TestSegmentDimensionLookup` (Python) and `cross_dimension_and_*` (Rust).
343
+
344
+ ## 0.8.0
345
+
346
+ Plan 233 remediated four escalated defect families, and this is the release that
347
+ delivers them. Everything below sat merged on `main` and unpublished for the
348
+ length of that plan — 20 commits, twelve breaking changes — because the version
349
+ never moved off `0.7.13`.
350
+
351
+ **Read this section before upgrading from `0.7.x`.** Three of these change live
352
+ behaviour rather than only types: segment-targeted payloads stop showing to
353
+ users outside the segment, `sdk.dismiss()` starts writing suppression where it
354
+ previously did nothing, and a converted placement stops coming back. Each is the
355
+ fix working, and each is visible to your users the moment you upgrade.
356
+
357
+ ### The `tenantId` init option is now the authority over a Playbook's `tenant_id`
358
+
359
+ - **What changed** — the Playbook's `tenant_id` used to win, and the init option
360
+ was a fallback applied only to legacy artifacts. That combination killed every
361
+ integration that served a canonical Playbook *and* passed the tenant at init:
362
+ the artifact had no `tenant_id` to win with, and the option was withheld
363
+ because the artifact was canonical, so init threw. The init option now wins.
364
+ The artifact's `tenant_id` is a **guard** — it exists so the CLI can refuse to
365
+ upload a config to the wrong tenant — and on a mismatch the SDK warns, naming
366
+ both values, and proceeds. It never fails init.
367
+ - **Landed in** — `0.8.0`
368
+ - **Fail-closed in** — n/a. This change makes init *succeed* where it used to
369
+ throw. The old behaviour was the failure.
370
+ - **Proving test** — `web-sdk/config-target-precedence-init.test.ts`
371
+
372
+ ### Unrecognized and missing init options are compile errors
373
+
374
+ - **What changed** — init options are exact-checked. A typo (`apikey` for
375
+ `apiKey`) or a stale option is a type error rather than a value the SDK
376
+ silently ignores. This catches the case TypeScript's excess-property check
377
+ never saw: options assembled in a variable, or in an un-annotated `useMemo`,
378
+ which is exactly how React integrations build them.
379
+ - **Landed in** — `0.8.0`
380
+ - **Fail-closed in** — same. Unknown keys were ignored at runtime before and
381
+ still are; what changed is that you cannot write them.
382
+ - **Proving test** — `web-sdk/init-options-exactness.test-d.ts`
383
+
384
+ ### A truncated Playbook is a compile error at the `localRuntime` boundary
385
+
386
+ - **What changed** — `UnvalidatedConfigArtifact` was `Record<string, unknown>`
387
+ and now structurally requires the body arrays (`plans`, `entitlements`,
388
+ `entitlement_rules`, `segments`, `content_ui_paths`). A Playbook missing one is
389
+ now caught by `tsc` instead of throwing at init.
390
+ - **Who this breaks** — only a caller who **explicitly annotated** their artifact
391
+ `Record<string, unknown>`; that must now be narrowed. A JSON module import and
392
+ a fetched artifact (`await res.json()`, typed `any`) both still compile with no
393
+ cast.
394
+ - **Landed in** — `0.8.0`
395
+ - **Fail-closed in** — same version, at compile time. Runtime validation was
396
+ already fail-closed and is unchanged.
397
+ - **Proving test** — `web-sdk/playbook-structural.test-d.ts`
398
+
399
+ ### Init failure is observable without an SDK instance
400
+
401
+ - **What changed** — when init fails the provider used to log and render children
402
+ anyway, so the SDK simply never started and every diagnostic probe was
403
+ unreachable *in precisely the failure mode that matters most*. `initStatus` is
404
+ now on the React context and is readable when the SDK instance is `null`, with
405
+ a non-empty `remediation` string on every init-path error.
406
+ - **Landed in** — `0.8.0`
407
+ - **Fail-closed in** — same.
408
+ - **Proving test** — `web-sdk/react/RevTurbineProvider.init-status.test.tsx`
409
+
410
+ ### The rendered theme resolves through the branding ladder
411
+
412
+ - **What changed** — `RevTurbineProvider` read `playbook?.theme` directly, so the
413
+ `branding` init option never reached `useRevTurbineTheme()`. `getBranding()`
414
+ and the rendered theme disagreed; that divergence *was* the defect. Both now
415
+ resolve from the same rung.
416
+ - **Landed in** — `0.8.0`
417
+ - **Fail-closed in** — same.
418
+ - **Proving test** — `web-sdk/react/RevTurbineProvider.branding-ladder.test.tsx`
419
+
420
+ ### `RevTurbineThemeProvider` merges partial themes, and an app-mounted one wins
421
+
422
+ - **What changed** — a partial theme replaced the defaults wholesale instead of
423
+ merging, so supplying one token dropped every other. Partial themes now merge.
424
+ Separately: if your app mounts its own `RevTurbineThemeProvider`, it wins over
425
+ the SDK's internal default, and a conflict warns in development.
426
+ - **Landed in** — `0.8.0`
427
+ - **Fail-closed in** — same.
428
+ - **Proving test** — `web-sdk/theme/ThemeContext.partial.test.tsx`,
429
+ `web-sdk/react/RevTurbineProvider.theme-nesting.test.tsx`
430
+
431
+ ### Light/dark schemes no longer require re-initializing the SDK
432
+
433
+ - **What changed** — additive. A `colorScheme` option and `setColorScheme()` swap
434
+ palettes while keeping SDK instance identity stable. The previous workaround
435
+ was re-initializing the SDK, which discarded its state.
436
+ - **Landed in** — `0.8.0`
437
+ - **Fail-closed in** — n/a, additive.
438
+ - **Proving test** — `web-sdk/react/RevTurbineProvider.color-scheme.test.tsx`
439
+
440
+ ### `segment_chips` are enforced in the decision path
441
+
442
+ - **What changed** — payload `segment_chips` were evaluated by
443
+ `explainPlacementDecision` but **not** by the decision path itself, so the
444
+ probe and the decision disagreed and a segment-targeted payload could show to a
445
+ user outside the segment. Both now call one shared helper, in every port.
446
+ - **Who this breaks** — an integration relying, knowingly or not, on
447
+ segment-targeted payloads showing to everyone will now see them correctly
448
+ restricted. **There is no grace period**: this was ruled a bug, not a behaviour
449
+ change.
450
+ - **Landed in** — `0.8.0`
451
+ - **Fail-closed in** — same version, and in all three runtimes together.
452
+ - **Proving test** — `tests/parity/fixtures/segment_chip_targeting.json` and
453
+ `tests/parity/fixtures/segment_evaluation.json`, run byte-identical across
454
+ TypeScript, Python and Rust.
455
+
456
+ ### Dismissal actually suppresses, at the authored window
457
+
458
+ - **What changed** — two defects. The interaction-state **read** omitted
459
+ `treatmentId` while the **write** included it, and the controllers always
460
+ populate it, so the keys could never match and dismissal suppression was dead
461
+ for every React integration. And the window was a hardcoded 24-hour literal
462
+ passed explicitly at every call site, which made both the authored
463
+ `cooldown_after_dismiss_days` and the 7-day default unreachable. The window now
464
+ resolves: explicit caller value → **authored config** → SDK default.
465
+ `remind_later_minutes` resolves independently, so "remind me" no longer means
466
+ "hide for a week".
467
+ - **Who this breaks** — dismissal now suppresses where it previously did nothing.
468
+ An integration that layered its own suppression on top may double-suppress.
469
+ - **Landed in** — `0.8.0`
470
+ - **Fail-closed in** — same. Note the *shape* of this one: it shipped in plan 167
471
+ with a proving test that dismissed **without** a `treatmentId`, so both keys
472
+ fell back to `'default'`, matched, and the feature appeared to work for a
473
+ month.
474
+ - **Proving test** — `web-sdk/customer-side-cooldown-suppression.test.ts`
475
+
476
+ ### A dismissal is no longer treated as "no match"
477
+
478
+ - **What changed** — `FixedSurfaceSlot` rendered its `fallback` whenever
479
+ `!visible`, and dismissing sets `visible: false` — so closing a placement put
480
+ the fallback back in the same space, a ghost replacing the thing just
481
+ dismissed. A slot now distinguishes the two: no match still renders `fallback`;
482
+ a dismissal renders nothing. `FixedSurfaceSlot` also gains `onDismissed`, and
483
+ `MessageSurfaceSlot`'s `onDismissed` — a public prop whose handler was built
484
+ and then discarded, so it could never fire — now works.
485
+ - **Who this breaks** — a host working around the ghost (one integration used a
486
+ `MutationObserver`) can remove the workaround. `MessageSurfaceSlot`'s
487
+ `onDismissed` goes from never firing to firing.
488
+ - **Landed in** — `0.8.0`
489
+ - **Fail-closed in** — same.
490
+ - **Proving test** — `web-sdk/placements/FixedSurfaceSlot.dismissal.test.tsx`
491
+
492
+ ### `convert()` retires the placement instead of only reporting it
493
+
494
+ - **What changed** — `convert()` emitted a `placement_interaction` event and
495
+ stopped: it never wrote the terminal state, so **a user who converted kept
496
+ being shown the upsell they had just paid for**. `dismiss()` and `snooze()` had
497
+ the identical report-but-never-act shape. All three now route through
498
+ `trackTreatmentInteraction`.
499
+ A second defect sat behind the same symptom: nothing in the decision path ever
500
+ *read* the terminal state. `isRetired` was written by `recordConversion` and
501
+ had no caller, so a 5-minute transient window was the only thing hiding a
502
+ converted placement — the upsell returned five minutes after checkout even
503
+ through the controller path. `getPlacementDecision` now checks retirement and
504
+ answers `retired_by_conversion`.
505
+ - **Who this breaks** — `sdk.dismiss()` now writes suppression state where it
506
+ previously did nothing.
507
+ - **Landed in** — `0.8.0`
508
+ - **Fail-closed in** — same.
509
+ - **Proving test** — `web-sdk/convert-retires-placement.test.ts`
510
+
511
+ ### New: a slot-inventory probe
512
+
513
+ - **What changed** — additive. `sdk.getRegisteredSlots()` returns the slots this
514
+ app has mounted, and `sdk.diagnoseSlotInventory()` diffs them against the
515
+ Playbook's placement triggers in both directions. A placement targeting a slot
516
+ no code mounts **can never show** and nothing reported it; a config-side audit
517
+ cannot see call sites.
518
+ - **Landed in** — `0.8.0`
519
+ - **Fail-closed in** — n/a, additive and diagnostic.
520
+ - **Proving test** — `web-sdk/slot-inventory-probe.test.ts`
521
+
522
+ ---
523
+
524
+ ## 0.7.0
525
+
526
+ ### `soft_block` → `block_with_upsell`
527
+
528
+ - **What changed** — the enforcement mode `soft_block` was renamed to
529
+ `block_with_upsell`, and with it the reason codes the SDKs return:
530
+ `usage_limit_reached_soft_block` → `usage_limit_reached_block_with_upsell`, and
531
+ `credit_balance_exhausted_soft_block` → `credit_balance_exhausted_block_with_upsell`.
532
+ **Any integration branching on the old reason string stops matching.**
533
+ - **Landed in** — `0.7.0`
534
+ - **Fail-closed in** — n/a. Behaviour is unchanged, and `soft_block` is still
535
+ accepted wherever it appears in an already-compiled Playbook, decoding to
536
+ exactly the decision it always made. Existing payloads keep working without
537
+ recompilation; only the **string you match on** moved.
538
+ - **Proving test** — the cross-language parity corpus (`tests/parity/`), which
539
+ asserts the reason codes byte-identically across all three runtimes.
540
+
541
+ ---
542
+
543
+ ## 0.6.0
544
+
545
+ ### The server-side decision methods are gone
546
+
547
+ - **What changed** — removed `RevTurbineServer.evaluate` / `getPlacement` /
548
+ `checkEntitlement` / `can` / `getTrialStatus` (Node), and the whole
549
+ `revturbine_server` module (Python).
550
+ - **Why the breakage is nominal** — every removed method called a hosted decision
551
+ endpoint that no longer exists, so each had been returning a **network error**
552
+ since that endpoint was deleted, in two languages, unnoticed — because the
553
+ cross-port gate only ever asked whether a method of that *name* existed.
554
+ - **What stays** — `RevTurbineServer` itself, and `createClientSession()`, which
555
+ mints the browser-safe token the client SDK's `clientSession` callback
556
+ consumes. An integration using the class only for that is unaffected.
557
+ - **Replacement** — evaluation is a pure function of (user context, Playbook) and
558
+ runs in the SDK. Use `LocalEvaluationServer` (Node) or `RevTurbineCustomerSdk`
559
+ (Python).
560
+ - **Landed in** — `0.6.0`
561
+ - **Fail-closed in** — effectively before `0.6.0`: the methods were already
562
+ erroring. `0.6.0` made the failure a compile/import error instead of a runtime
563
+ one.
564
+ - **Proving test** — `tests/no-decision-endpoint.test.ts`
565
+
566
+ ---
567
+
568
+ ## 0.5.0
569
+
570
+ ### The access gate denies during server rendering
571
+
572
+ - **What changed** — the gate is constructed inside a `useEffect`, and effects do
573
+ not run during SSR. So on the server there was no gate at all: `isLoading` read
574
+ false, `result` was null, `denied` computed false — and the gate emitted its
575
+ **children**. The server-rendered HTML for a gated feature was literally the
576
+ paid affordance. It now denies until a real verdict exists.
577
+ - **Why this one mattered** — it is a fail-open in the one place the client
578
+ cannot correct it. A crawler or a reader that never runs JS sees the paid
579
+ content.
580
+ - **Landed in** — `0.5.0`
581
+ - **Fail-closed in** — `0.5.0`. This *is* the fail-closed change.
582
+ - **Proving test** — `web-sdk/placements/AccessGateSurfaceSlot.ssr.test.tsx`
583
+
584
+ ---
585
+
586
+ ## 0.4.0
587
+
588
+ ### An unresolvable plan identity denies in Python and Rust
589
+
590
+ - **What changed** — the headless ports granted where TypeScript denied when a
591
+ user context carried no resolvable plan identity. The ports now deny, matching
592
+ TypeScript.
593
+ - **Landed in** — `0.4.0`
594
+ - **Fail-closed in** — `0.4.0` for Python and Rust; TypeScript already denied.
595
+ **The window between `0.3.0` and `0.4.0` is the expensive one** — a context
596
+ that failed closed in TS silently granted in the other two runtimes.
597
+ - **Proving test** — the cross-language parity corpus, which is what surfaced the
598
+ divergence: the gate had been green while `deriveLocalEntitlementFromConfiguredRules`
599
+ sat unreached by any fixture.
600
+
601
+ ---
602
+
603
+ ## 0.3.0
604
+
605
+ This release is where the `plan: { id }` → `plan_handle` migration landed. It was
606
+ a **minor** bump carrying breaking changes, authorised because there were no
607
+ active tenants and only in-org code referenced the changed surfaces.
608
+
609
+ ### `plan: { id }` → `plan: { handle }` / `plan_handle`
610
+
611
+ - **What changed** — a plan's matching identity is its `unique_handle`.
612
+ `plan.id` is DB-internal and no longer participates in matching anywhere, in
613
+ any of the three runtimes.
614
+ - **Symptom if you do not migrate** — a context whose only plan signal is
615
+ `plan.id` now matches **no** plan-targeted rule and fails closed. It does not
616
+ error; it simply has no plan.
617
+ - **Landed in** — `0.3.0`
618
+ - **Fail-closed in** — `0.3.0`.
619
+ - **Proving test** — `web-sdk/user-context-exactness.test-d.ts` plus the
620
+ plan-identity parity fixtures
621
+ (`tests/parity/fixtures/entitlement_plan_identity_is_handle.json`,
622
+ `entitlement_no_plan_identity_denies.json`).
623
+
624
+ ### `identify()` no longer accepts an arbitrary traits bag
625
+
626
+ - **What changed** — unknown top-level context keys are a compile error.
627
+ Free-form values go under `custom`, via `update()`.
628
+ - **Landed in** — `0.3.0`
629
+ - **Fail-closed in** — `0.3.0`, at compile time.
630
+ - **Proving test** — `web-sdk/user-context-exactness.test-d.ts`
631
+
632
+ ### `local_runtime_default_allow` → `entitlement_not_in_playbook`
633
+
634
+ - **What changed** — the reason code was renamed with **no deprecated alias**,
635
+ and the headless runtimes' terminal fallback now **denies** where it used to
636
+ grant.
637
+ - **Landed in** — `0.3.0`
638
+ - **Fail-closed in** — `0.3.0`. The rename and the fail-closed flip shipped
639
+ together, deliberately: the old name described a grant, so keeping it as an
640
+ alias would have described the new behaviour incorrectly.
641
+ - **Proving test** — the fail-closed suites in `web-sdk/` and the corresponding
642
+ Python and Rust port suites.
643
+
644
+ ---
645
+
646
+ ## Before 0.3.0
647
+
648
+ Versions `0.1.0` through `0.2.91` predate this changelog. Breaking changes in
649
+ that range are recorded only in commit history and release notes. If you are
650
+ upgrading from `0.2.x`, read the `0.3.0` section first — it is the release that
651
+ changed plan identity, and it is the one most likely to affect you.