@revturbine/sdk 0.11.5 → 0.11.7

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 CHANGED
@@ -1,1605 +1,1714 @@
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
26
- > was thin (4 of ~73 methods) until BL-0005 (2026-09-22, no release — TSDoc
27
- > and snapshot only) widened it to **34** of `RevTurbineCustomerSdk`'s **67**
28
- > non-private methods, plus 5 provider/hook entry points
29
- > (`initRevTurbine`, `RevTurbineProvider`, `useCan`, `useEntitlement`,
30
- > `useGatedAction`) — every method a customer-facing guide, skill, the
31
- > starter, or a demo app calls directly. The remaining ~33 methods are
32
- > internal-only paths (called only by the SDK's own placement components,
33
- > React bindings, or other internal packages) and stay untagged, so a
34
- > breaking change to one of *those* still will not be caught automatically —
35
- > it depends on whoever writes the PR. Entries here are reliable; the absence
36
- > of an entry for a currently-untagged method is not yet proof that nothing
37
- > changed.
38
-
39
- > **Note on 0.x.** These packages are pre-1.0, so breaking changes ship in the
40
- > **minor** position (`0.6.0` → `0.7.0`), not the major. `npm`'s caret on a `0.x`
41
- > range does **not** span minors, so `^0.7.0` will not silently pull `0.8.0`.
42
-
43
- Built-in decision reason values have a separate reviewed
44
- [compatibility baseline](docs/reason-code-contract.md). Live fixture checks
45
- protect those values independently of `@public` method tags; baseline changes
46
- also require a changelog entry.
47
-
48
- ---
49
-
50
- ## 0.11.5
51
-
52
- ### `registerSurfaceSlot()` no longer writes to the control plane (BL-0197)
53
-
54
- **What changed.** Outside `local_only`, `registerSurfaceSlot()` used to mirror
55
- every slot to the server: `upsertSurfaceSlot()` PUT a legacy surface-slot body
56
- (`{name, slug, slot_type, status, targeting_rules, content, priority,
57
- metadata}`) to `/api/placements/<slot id>`, then POSTed it to `/api/placements`
58
- on the 404. `/api/placements` is RevTurbine's **authored-config** CRUD; it
59
- validates against `PlacementSchema`, which requires `handle` and `category`, so
60
- that body could only ever be rejected. In production every non-`local_only`
61
- slot registration failed with `surface_slot_create_failed:422` and the React
62
- runtime turned the rejection into a `slot_error`
63
- (`dogfood_event_explorer`, 2026-09-24 18:36 UTC, request
64
- `030ccf51-d74b-4d55-bf4e-2a929a4ab297`).
65
-
66
- The write was also wrong in principle. On a same-origin integration the
67
- signed-in user's session cookie authenticates the request, so a body the route
68
- *did* accept would have created a draft placement in the tenant's Playbook just
69
- from loading a gated page. Only the 422 prevented that.
70
-
71
- `registerSurfaceSlot()` is now **client-local**: it records the slot in the
72
- in-process registry and resolves, with no network write of any kind. A write
73
- happens only when you configure `endpointOverrides.surfaceSlots` (or
74
- `custom_endpoints.surfaceSlots`) to point at a slot-inventory service you run —
75
- the same shape `persistPlacementTypes()` already uses. There is **no fallback
76
- to `/api/placements`** under any configuration.
77
-
78
- Nothing is lost by removing it. Surface-slot discovery is **ingestion-driven**:
79
- the SDK already emits `slot_evaluated` / `slot_filled` / `slot_empty` /
80
- `slot_suppressed` / `slot_error` and the `placement_*` lifecycle through
81
- `/api/track`, carrying `surface_slot_id`, `slot_name`, `template_ids`,
82
- `surface_type`, `category`, `decision_source` and `reason_codes`, and the
83
- pipeline derives discovered slots from that telemetry. `SurfaceSlotSchema` is a
84
- runtime-source (DISCOVERED) schema with `first_seen` / `last_seen` — it was
85
- never meant to be written by a browser.
86
-
87
- **Landed in.** `0.11.5` (BL-0197).
88
-
89
- **Fail-closed in.** `0.11.5` — same version. The removed path had no silent
90
- mode: before this release it threw `surface_slot_create_failed:<status>` on
91
- every default-transport registration, so there is no window where the old
92
- behaviour was quietly tolerated. An integration that genuinely relied on the
93
- write must set `endpointOverrides.surfaceSlots`; one that did not was already
94
- seeing `slot_error`.
95
-
96
- **Proving test.** `web-sdk/surface-slot-registration-no-write.test.ts` — the
97
- default transport performs zero writes (fetch spy, no `/api/placements` call)
98
- and resolves even when the route replays the production 404/422; the override
99
- path still PUTs, and falls back to POST on the override base, never on
100
- `/api/placements`. `e2e/journey.transport.spec.ts` no longer stubs
101
- `/api/placements` with a 200 (the stub that hid this): it records and aborts,
102
- and `assertNoPlacementWrites()` fails the test if anything calls it.
103
-
104
- **Ports.** No change — the Python and Rust SDKs never had a slot-registration
105
- write.
106
-
107
- ---
108
- ## 0.11.4
109
-
110
- ### The treatment-interaction wire record now carries `rule_handle` (BL-0200)
111
-
112
- **What changed.** `flushInteractionQueue`'s projection onto
113
- `POST /api/events/interactions` now includes `rule_handle`, taken from the same
114
- `ruleHandle` on `RevTurbineTreatmentInteractionInput` that 0.11.3 began stamping
115
- on the `placement_interaction` clickstream event.
116
-
117
- 0.11.3 deliberately kept it off this record, and #530 asserted the omission with
118
- a "never reaches the treatment-interaction wire record" test. The reasoning was
119
- that the clickstream carries payload fields inside a JSON column — readable the
120
- moment a producer sends them — while this record is column-shaped
121
- (`placement_presentations`) and a new key needed a datasource migration.
122
-
123
- The migration has since landed (revturbine-web ledger 015 added
124
- `placement_exposure_attribution.rule_handle`), and the field is now declared on
125
- `TreatmentInteractionInputSchema` (`revturbine-scaffold#394`, published as
126
- `@revt-eng/*` v0.1.342). Sending it lets the app record the rule an exposure was
127
- **decided by**, on the base exposure row, instead of waiting for the attribution
128
- worker to reconstruct one from the clickstream when a conversion lands — which
129
- is what makes presentation-grain analytics cuts per rule honest rather than
130
- computed over the converted subset.
131
-
132
- **Nothing to change in your integration.** `ruleHandle` was already public and
133
- already populated by every `PlacementController` path; this only widens where the
134
- value is sent. Spread, not set: the key is **absent** when no decision was in
135
- scope. The contract accepts an explicit `null` for "a rule was selected and none
136
- matched", but the SDK cannot distinguish that from "no decision in scope" —
137
- `PlacementOutput.rule_id` is simply missing in both — so it asserts neither.
138
-
139
- **No `@public` surface moved**, hence a patch release.
140
-
141
- ## 0.11.3
142
-
143
- ### `placement_interaction` now carries `rule_handle` (BL-0182)
144
-
145
- **What changed.** #527 (BL-0062) stamped `rule_handle` at every verdict emit
146
- site by reading `decision.output.rule_id`, but `placement_interaction` is not
147
- a lifecycle event and does not spread `placementLifecycleBase` — its
148
- placement keys are all optional, because a bare `trackTreatmentInteraction`
149
- caller may supply none of them — so the click between exposure and outcome
150
- was the one step of the funnel with no rule key. That is the step
151
- click-through and CTR are computed over: the funnel was sliceable by rule at
152
- both ends and not in the middle.
153
-
154
- Five emit sites now reach the same stamped payload: `trackTreatmentInteraction`
155
- (the projection itself), `PlacementController.trackInteraction`
156
- (dismiss / remind_me_later / cta_clicked / cta_completed — and therefore every
157
- React hook and `useSurfaceSlot` / `FixedSurfaceSlot` click handler built on
158
- it), `PlacementController.fireImpression`, `trackOutputInteraction`
159
- (output-addressed `convert()` / `dismiss()` / `snooze()`), and the
160
- server-action `trackResult` resolver.
161
-
162
- Spread, not set: `rule_handle` is **absent** when no decision was in scope
163
- (a bare `trackTreatmentInteraction` call with no placement context), and
164
- `null` on the wire means "a rule was selected and none matched" — conflating
165
- the two would poison the slice's coverage numbers. The unknown-output branch
166
- of `trackOutputInteraction` stamps nothing on purpose: no decision from this
167
- SDK produced that output, so there is no rule to name.
168
-
169
- Companion to `revturbine-scaffold#391`, which added `rule_handle` to the
170
- `placement_interaction` payload contract and `ruleHandle` to
171
- `RevTurbineTreatmentInteractionInput` (published as `@revt-eng/*` v0.1.339,
172
- pinned in this release).
173
-
174
- **No wire-shape change beyond the new optional field.** `rule_handle` is
175
- optional-and-nullable on the schema side (#391), which keeps every
176
- already-deployed SDK out of `quarantineVerdict`. `@public` signatures are
177
- unchanged (`web-sdk/generated/public-api.json` untouched) and
178
- `flushInteractionQueue` still maps the treatment-interaction record field by
179
- field, so `ruleHandle` never reaches a customer webhook.
180
-
181
- **Python and Rust are unaffected.** Neither port emits events.
182
-
183
- **Landed in** `0.11.3` (BL-0182, #530).
184
-
185
- **Fail-closed in** not applicable — additive field, nothing old is rejected.
186
-
187
- **Proving test:** `web-sdk/customer-side-canonical-interaction.test.ts`.
188
-
189
- ---
190
-
191
- ### A gate no longer paywalls forever when it loses the cold-start config race (BL-0179)
192
-
193
- **What changed.** BL-0177 (below) split `config_unavailable` into a transient and
194
- a terminal state for **placements** and left `checkEntitlement` on the old
195
- behaviour. So the same cold-start race denied entitlements instead: while the
196
- Playbook load was in flight, `checkEntitlement` — and therefore `can()`,
197
- `gate()`, `useCan`, `useEntitlement` and `useGatedAction` — returned the
198
- fail-closed deny with reason `config_unavailable`, which is indistinguishable
199
- from a rule denial. A gate mounted before config resolution rendered its paywall
200
- and never re-evaluated, hiding a feature the user had paid for.
201
-
202
- The entitlement path now takes the same two-state split:
203
-
204
- | state | when | behaviour |
205
- |---|---|---|
206
- | **transient** | a Playbook load is in flight (`getPlaybookLoadState() === 'loading'`) | `checkEntitlement` waits for it (bounded, 4s) and re-evaluates. If the load outruns that bound, `EntitlementGate` **parks** the deny instead of publishing it — `result` stays `null`, `denied` stays `false`, `isLoading` stays `true`, and **no** `gate_evaluated` is emitted — then re-checks when the load settles, at most twice per cycle. |
207
- | **terminal** | no config provider is configured, or the load settled without a config | unchanged: fail-CLOSED deny, reason `config_unavailable` in Server mode / `entitlement_not_in_playbook` in local mode, with its `resolution_failure` diagnostic and exactly one `gate_evaluated` carrying the verdict (and its `rule_handle`). |
208
-
209
- The emission rule is the one worth stating plainly: **exactly one
210
- `gate_evaluated` per settled verdict, and none for the transient state** — a
211
- lost race is not an evaluation and must not enter the gate funnel as a denial.
212
-
213
- **No API, wire or reason-code change.** `@public` signatures are unchanged
214
- (`web-sdk/generated/public-api.json` is untouched), both reason codes keep their
215
- meanings, and `tests/reason-contract.json` is unchanged. A `limited`-but-allowed
216
- result is untouched; the fail-closed contract for terminal unavailability is
217
- untouched.
218
-
219
- **Python and Rust are unaffected by design**, for the same reason as BL-0177:
220
- both take the Playbook as a required constructor argument, do no I/O, and have no
221
- not-yet-loaded window.
222
-
223
- **Landed in.** `0.11.3` (BL-0179, #529).
224
-
225
- **Fail-closed in.** Not applicable — terminal behaviour is unchanged; only the
226
- transient race is now absorbed.
227
-
228
- **Proving test.** `web-sdk/react/useCan.config-race.test.tsx` (a gate mounted
229
- mid-race ends on the rule's verdict, allow and deny) and
230
- `web-sdk/entitlement-config-race.test.ts` (both states, the emission rule, and
231
- the bounded post-bound retry for `PlacementController` and `EntitlementGate`,
232
- reached by injecting a `0` wait bound rather than with fake timers).
233
-
234
- ---
235
-
236
- ### A slot no longer paints its fallback forever when it loses the cold-start config race (BL-0177)
237
-
238
- **What changed.** `getPlacementDecision` returns `config_unavailable` when the
239
- Playbook has not arrived, uncached, specifically so a retry can succeed — but
240
- nothing retried. `useSurfaceSlot` / `FixedSurfaceSlot` decide once per mount, so
241
- on a cold `revturbine_server` load, whenever the first decision beat the two-hop
242
- bootstrap → `/api/sdk/config` chain, the slot rendered its `fallback`
243
- permanently and every integration had to hand-roll a retry.
244
-
245
- `config_unavailable` now has two distinct states and the SDK handles each:
246
-
247
- | state | when | behaviour |
248
- |---|---|---|
249
- | **transient** | a Playbook load is in flight | `getPlacementDecision` waits for it (bounded, 4s) and re-resolves. If the load outruns that bound, `PlacementController` re-decides when it settles — at most twice per load cycle. The slot stays in `isLoading` meanwhile and emits **no** `placement_resolved` / `slot_empty`: a lost race is not a resolution and must not enter the funnel as one. |
250
- | **terminal** | no config provider is configured, or the load settled without a config | unchanged from before: the decision is `config_unavailable`, `visible: false`, uncached, with the resolution-failure diagnostic and the lifecycle + slot events. Bounded retries mean it settles rather than spinning. |
251
-
252
- **No API, wire or reason-code change.** `config_unavailable` is still the reason
253
- code, the decision shape is untouched, and `tests/reason-contract.json` is
254
- unchanged. Customer code that already hand-rolls a retry keeps working — it is
255
- now redundant, not wrong.
256
-
257
- **Python and Rust are unaffected by design.** Both ports take the Playbook as a
258
- required constructor argument and raise on a missing or malformed one
259
- (`server-python/src/revturbine/sdk.py` `__init__`, `server-rust/src/sdk.rs`
260
- `new`), perform no I/O, and never emit `config_unavailable` — there is no
261
- not-yet-loaded window to retry. This is a browser/hosted-mode state only.
262
-
263
- **Landed in.** `0.11.3` (BL-0177, #528).
264
-
265
- **Fail-closed in.** Not applicable — the terminal behaviour is unchanged; only
266
- the transient race is now absorbed.
267
-
268
- **Proving test.** `web-sdk/placements/FixedSurfaceSlot.config-race.test.tsx`
269
- (both states), plus `e2e/journey.transport.spec.ts`, whose two hand-rolled retry
270
- loops were deleted — a retry loop reappearing there is the regression signal.
271
-
272
- ---
273
-
274
- ## 0.11.1
275
-
276
- ### The generated schema *types* gained their `Playbook*` names (BL-0165)
277
-
278
- **What changed.** `0.11.0` renamed every option, keyword and method that carries a
279
- Playbook. It did not rename the **generated type identifiers**, which come from
280
- scaffold and are vendored here — so a `0.11.0` integration still had to import
281
- `ExportedConfigSegmentsItem` to name a segment. Those types now have `Playbook*`
282
- spellings too.
283
-
284
- | deprecated | canonical |
285
- |---|---|
286
- | `ExportedConfigSegmentsItem` | `PlaybookSegmentsItem` |
287
- | `ExportedConfigSegmentsItemPredicatesItem` | `PlaybookSegmentsItemPredicatesItem` |
288
- | `ExportedConfigPlacementItem` | `PlaybookPlacementItem` |
289
- | `ExportedConfigUiPathActionType` | `PlaybookUiPathActionType` |
290
- | `ExportedConfig` | `Playbook` (already shipped in `0.11.0`) |
291
-
292
- **This renames nothing on the wire.** The names are generated from exported keys
293
- of scaffold's Zod barrel: both spellings resolve to the **same schema object**, so
294
- no payload field, default or validation rule differs between them, and the OpenAPI
295
- component names are unchanged (they come from `.meta({ id })` on the underlying
296
- schemas, not from the key). A Playbook written by an older SDK parses byte-for-byte
297
- identically. Scaffold's payload contract — `bundle_schema_version` /
298
- `bundle_min_readable_schema_version` — is untouched, so there is no minimum-reader
299
- floor to raise.
300
-
301
- **The aliases are duplicate definitions, not type aliases.** Scaffold's generators
302
- emit one full TS type, Pydantic model and serde struct per barrel key rather than an
303
- alias, so in Python (`revturbine.types`) and Rust (`revturbine::types`) the old
304
- names remain as their own complete models/structs, generated from the same schema.
305
- No hand-written shim exists in either port, and none is wanted: it would collide
306
- with the generated definition. In TypeScript the `core` barrel re-exports the old
307
- pair with `@deprecated` TSDoc.
308
-
309
- Requires `@revt-eng/schema` / `@revt-eng/core` ≥ 0.1.335, which added the
310
- `Playbook*` keys upstream (scaffold #387).
311
-
312
- Ruling: Kent, 2026-09-23 (BL-0156 — `ExportedConfig` naming deprecated, Playbook
313
- canonical).
314
-
315
- **Landed in** `0.11.1` (additive — every old name still resolves).
316
- **Fail-closed in** `0.12.0`, when BL-0167 removes the `ExportedConfig*` keys from
317
- scaffold's barrel and the generated definitions disappear from the vendored types
318
- along with them. That is the same window as the `0.11.0` option/method aliases, so
319
- an integration has one removal to absorb, not two.
320
-
321
- **Proving test:** scaffold's `src/core/zod/playbook-generated-names.test.ts` (each
322
- `Playbook*` key is the same object as its `RevTurbineConfig*` and `ExportedConfig*`
323
- counterparts; a legacy `export-config.json` payload parses to an equal result
324
- through both spellings of the config schema), plus this repo's
325
- `scripts/check-vendored-types.mjs` (the vendored py/rs copies agree with the pin).
326
-
327
- **Public-API snapshot: unchanged.** `pnpm check:public-api` still reports the same
328
- **62** `@public` exports and `web-sdk/generated/public-api.json` is byte-identical,
329
- because the `core` barrel's type re-exports carry no `@public` tag. Recorded here
330
- rather than left implicit: a rename touching the SDK's type vocabulary that moves
331
- *no* snapshot entry is worth stating, so a reader does not go looking for the diff.
332
- No existing export's signature changed and none was removed.
333
-
334
- ## 0.11.0
335
-
336
- ### `Playbook` is the canonical name for every option that carries one (BL-0156)
337
-
338
- **What changed.** `ExportedConfig` is dead vocabulary. Plan 118 renamed the
339
- domain object to **Playbook** and plan 104 renamed the schema type to
340
- `RevTurbineConfig`, but the *option*, *keyword* and *method* names that carry a
341
- Playbook around the SDK still spelled it `exportedConfig` / `exported_config`.
342
- Every one of them now has a `Playbook`-named counterpart; every old name stays
343
- as a deprecated alias that still works.
344
-
345
- TypeScript:
346
-
347
- | deprecated | canonical |
348
- |---|---|
349
- | `localRuntime.exportedConfig` | `localRuntime.playbook` (already shipped; see below) |
350
- | `localRuntime.resolvers.resolveExportedConfig` | `localRuntime.resolvers.resolvePlaybook` |
351
- | `configProvider.getExportedConfig()` | `configProvider.getPlaybook()` |
352
- | `sdk.getExportedConfig()` | `sdk.getPlaybook()` |
353
- | `getPolicy().exportedConfigVersion` | `getPolicy().playbookVersion` |
354
- | `BrowserRuntimeOptions.exportedConfig` | `BrowserRuntimeOptions.playbook` |
355
- | `LocalEvaluationServerOptions.exportedConfig` | `LocalEvaluationServerOptions.playbook` |
356
- | `ExportedConfigProvider` (type) | `RevTurbineConfigProvider`, also exported as `PlaybookProvider` |
357
- | `ExportedConfig` (type) | `Playbook` |
358
-
359
- Python: `RevTurbineCustomerSdk(exported_config=…)`, `LocalRuntime(exported_config=…)`,
360
- `LocalRuntime.get_exported_config()`, `create_static_placement_resolver(exported_config=…)`,
361
- `derive_local_entitlement_from_configured_rules(exported_config=…)`,
362
- `configured_plan_name_from_exported_config`, `parse_exported_config_or_throw` and
363
- `static.ExportedConfig` all gained `playbook`-spelled counterparts.
364
-
365
- Rust: `configured_plan_name_from_exported_config` is now
366
- `configured_plan_name_from_playbook`, with the old name kept as the crate's
367
- first `#[deprecated]` item. Rust has no keyword arguments, so nothing else in
368
- that port was a caller-visible name.
369
-
370
- `RevTurbineConfigProvider`'s two accessors are both OPTIONAL on the interface so
371
- that an existing `implements RevTurbineConfigProvider` clause keeps compiling;
372
- the `configProvider` option intersects it with a union that still requires
373
- exactly one of them, so a provider implementing neither is a compile error where
374
- it is passed.
375
-
376
- Reading a deprecated name logs **one** development-build warning per runtime
377
- (one `DeprecationWarning` per process on Python) naming the canonical
378
- replacement — one flag for every alias, so an integration still spelling several
379
- of them gets a single line, not a wall. Production builds are silent. Requires
380
- `@revt-eng/core` ≥ 0.1.331, which made the same rename upstream (scaffold #380).
381
-
382
- Ruling: Kent, 2026-09-23 (BL-0156).
383
-
384
- **Landed in** `0.11.0`. **Fail-closed in** `0.12.0` for the `ExportedConfig`
385
- aliases — until then every old name works and warns once.
386
-
387
- **Proving test:** `web-sdk/playbook-option.test.ts` (canonical never warns, the
388
- alias still resolves, one warning across three read sites, both spellings reach
389
- the same Playbook at every public read site),
390
- `server-python/tests/test_playbook_option.py` (the same contract plus
391
- `get_exported_config()`), and `server-rust/src/user_context.rs`
392
- `playbook_rename_tests` (the alias returns exactly what the canonical returns).
393
-
394
- ### `localRuntime.playbook` is the canonical local-mode config key
395
-
396
- **What changed.** The record this file owed and never carried: `localRuntime.playbook`
397
- shipped earlier as the canonical spelling of `localRuntime.exportedConfig`, with
398
- `resolveLocalPlaybook()` as the single resolver deciding precedence, but no
399
- changelog entry was ever written for it — so an integration could not date the
400
- change. It is recorded here rather than back-dated, because the version it
401
- landed in is not the version this entry appears in and pretending otherwise is
402
- exactly the confusion the "Landed in" / "Fail-closed in" split exists to
403
- prevent. As of `0.11.0` the alias also warns once (it did not before).
404
-
405
- **Landed in** a release before `0.11.0` (additive; the alias never stopped
406
- working). **Fail-closed in** `0.12.0`, with the rest of the `ExportedConfig`
407
- aliases.
408
-
409
- **Proving test:** `web-sdk/playbook-option.test.ts` — `resolveLocalPlaybook`
410
- accepts either key, prefers `playbook`, and warns only for the alias.
411
-
412
- ### `publicKey` is the only browser credential; `ingestPublicKey` is gone (BL-0113)
413
-
414
- **What changed.** `0.10.0` made `publicKey` the browser credential and kept
415
- `apiKey` and `ingestPublicKey` working as aliases "for one minor". This is that
416
- minor:
417
-
418
- - **`ingestPublicKey` is removed from `RevTurbineInitOptions`.** Passing it is
419
- now a **type error**, including when the options are built in a variable
420
- (`ExactInitOptions` rejects it there too).
421
- - **`apiKey` is no longer read as a browser credential.** It still exists, and
422
- still means exactly one thing: the secret **server key**, for
423
- `@revturbine/sdk/server` and for the headless SDK on a backend. A browser init
424
- that supplies only `apiKey` now resolves **no** browser credential and fails
425
- at init, instead of silently sending a server key as the bearer.
426
-
427
- `resolveBrowserPublicKey()` reads `publicKey` and nothing else, and
428
- `resetBrowserKeyAliasWarning()` is gone with the warning it reset. Keyless
429
- local-only init is unchanged, and the plan 95 anonymous `sdk_init` beacon now
430
- keys off `publicKey` alone.
431
-
432
- Ruling: Kent, 2026-09-23 (BL-0113), closing the window plan 257 opened.
433
-
434
- **Landed in** `0.11.0`. **Fail-closed in** `0.11.0` — the same release. This IS
435
- the fail-closed half of the `0.10.0` entry below; there is no further tolerance
436
- window.
437
-
438
- **Proving test:** `web-sdk/public-key-option.test.ts` (`publicKey` is the bearer
439
- on ingest and every control-plane fetch; `apiKey` resolves nothing; nothing
440
- warns), `web-sdk/customer-side-ingest.test.ts` (the bearer no longer carries the
441
- server key), and `web-sdk/init-options-exactness.test-d.ts` (`ingestPublicKey`
442
- is a type error inline AND in a variable — if the key is ever re-added, `tsc`
443
- fails on the unused `@ts-expect-error`).
444
-
445
- ---
446
-
447
- ## 0.10.10
448
-
449
- ### Trial tokens now reach placement content, not only `getPersonalizationTokens()` (BL-0169)
450
-
451
- **What changed.** A placement whose copy authors `{{trial_days_remaining}}` or
452
- `{{trial_days_total}}` now renders the live values. Content interpolation sources
453
- its token map from the **output content itself**, so a provider-derived token
454
- only reaches the copy if the resolver writes it onto that content first. The
455
- usage lane always did (`usage_current`, `usage_limit`, `usage_percent`,
456
- `usage_remaining`, `reset_date`); the trial lane never did, so a `trial_ending`
457
- body reading `"{{trial_days_remaining}} days left"` shipped the literal
458
- `{{trial_days_remaining}}` to the end user — on every port. The two names were
459
- derived for `getPersonalizationTokens()` all along, which is why the gap survived:
460
- a host that read the token map saw the right numbers and a host that authored the
461
- token into copy did not.
462
-
463
- | | before | now |
464
- |---|---|---|
465
- | `getPersonalizationTokens().trial_days_remaining` | live value | live value (unchanged) |
466
- | content `{{trial_days_remaining}}` / `{{trial_days_total}}` | literal `{{…}}` | live value |
467
- | `outputContent.trial_days_remaining` / `_total` | absent | written when the plan provider carries a finite number |
468
-
469
- Identical on all three ports (`ts:local-resolver.ts`,
470
- `py:core/placements/local_resolver.py`, `rs:placements/static_resolver.rs`): the
471
- same two token names, a `Number.isFinite` guard, provider state winning over an
472
- authored content value of the same key, and the value passed through **without
473
- widening** — `day_number` 7 + `days_remaining` 3 is `trial_days_total` `10`, never
474
- `10.0` (BL-0155).
475
-
476
- **Who this reaches.** Any host whose placement copy authors either token. Copy
477
- that hard-coded one of those keys as a decorative literal now has it overwritten
478
- by live state — the same precedence the usage lane and
479
- `derivePlacementPersonalizationTokens` have always had. `{{trial_plan_name}}` and
480
- `{{trial_features_used}}` remain **not yet wired** by the placement-studio-ui
481
- spec's own note and are untouched.
482
-
483
- **Landed in.** `0.10.10`.
484
-
485
- **Fail-closed in.** `0.10.10` — absence still leaves the raw token standing
486
- rather than rendering `0`, so a host with no trial provider state sees exactly
487
- what it saw before.
488
-
489
- **Proving test.** Parity scenario `trial_tokens_in_content` under
490
- `"normalize": "preserve-representation"` (byte-locked ts ≡ py ≡ rs), plus
491
- `py:tests/placements/test_local_resolver.py` and `rs:tests/static_resolver.rs`
492
- `trial_tokens_are_injected_from_plan_provider_state` and its two neighbours,
493
- mirroring the scaffold-side `local-resolver.test.ts` cases. Needs
494
- `@revt-eng/core` 0.1.331 or later (scaffold #382) for the TS half; this release
495
- pins 0.1.332.
496
-
497
- ---
498
-
499
- ## 0.10.9
500
-
501
- ### The trial-status overlay no longer changes a number's type (BL-0155)
502
-
503
- **What changed.** The `UserTrialStatus` → `trial_*` PlanProviderState overlay now
504
- passes each numeric field through on every port, instead of two ports widening
505
- it to a float. The canonical behaviour is the TS SDK's
506
- `synthesizeProviderContext` (`web-sdk/customer-side.ts`, the `planTrialFields`
507
- block), which spreads every `UserTrialStatus` member verbatim: an integer
508
- `progress_percent` stays an integer on the provider state, and
509
- `trial_days_total` is `day_number + days_remaining` in the inputs' own kind, so
510
- `7 + 3` is `14`. The two server ports diverged from it:
511
-
512
- | field | Python before | Rust before | all three now |
513
- |---|---|---|---|
514
- | `progress_percent` → `trial_progress_percent` | `float(v)` | verbatim | verbatim |
515
- | `days_remaining` → `trial_days_remaining` | `float(v)` | verbatim | verbatim |
516
- | `usage_consumed` → `trial_usage_consumed` | `float(v)` | verbatim | verbatim |
517
- | `usage_limit` → `trial_usage_limit` | `float(v)` | verbatim | verbatim |
518
- | `in_trial` → `trial_active` | `bool(v)` | verbatim | verbatim |
519
- | derived `trial_days_total` | `float(a) + float(b)` | `f64` sum | `int + int` → `int`; any fractional half → float |
520
-
521
- **Who this reaches.** Only a host that reads the provider state it built —
522
- `revturbine::overlay_trial_status_on_plan_provider` on Rust, or the merged
523
- `PlanProviderState` on Python — and cares whether it holds `70` or `70.0`
524
- (serializing it, comparing it to an integer, or logging it). No decision
525
- changes: every reader in the trial-gating layer widens on read, which is exactly
526
- why the divergence survived. A non-numeric `day_number`/`days_remaining` half
527
- now derives no total on Python instead of raising, matching Rust's existing
528
- numeric guard.
529
-
530
- **Landed in.** `0.10.9`.
531
-
532
- **Fail-closed in.** `0.10.9` — there was no tolerated-old-shape window; the
533
- representation simply changes.
534
-
535
- **Proving test.**
536
- `server-python/tests/test_trial_overlay_upsert.py::test_overlay_preserves_integer_numeric_representation`
537
- and `server-rust/src/sdk.rs::overlay_preserves_integer_numeric_representation`
538
- — both assert the *type*, not just the value, because `100 == 100.0` in Python.
539
- Scenario `trial_overlay_integer_fields` locks that the three ports still decide
540
- identically from an all-integer trial status.
541
-
542
- > **Correction, 2026-09-23 (BL-0158, harness-only — no release).** This entry
543
- > originally said the parity corpus *cannot* see this class of divergence,
544
- > because `tests/parity/normalize.*` rule 4 collapses integral floats by design
545
- > and nothing in the corpus returned the overlaid provider state. Both halves
546
- > are now closed and the sentence no longer holds:
547
- > `trial_overlay_integer_fields` drives `resolveProviders` (already shipped on
548
- > all three runtimes) under a new per-scenario
549
- > `"normalize": "preserve-representation"` flag that turns the collapse off, so
550
- > `14` and `14.0` are different bytes and the cross-language byte-diff fails on
551
- > a widened field. No public API changed; the harness gained the ability to see
552
- > provider state.
553
-
554
- ---
555
-
556
- ## 0.10.8
557
-
558
- ### `{{recommended_plan_name}}` and `{{plan_name}}` no longer reach end users as raw tokens (BL-0121)
559
-
560
- **What changed.** The placement-decision lane now substitutes the five tokens
561
- the SDK resolves from the Playbook — `plan_name`, `plan_price`,
562
- `upgrade_plan_price`, `recommended_plan_handle`, `recommended_plan_name` —
563
- into `RevTurbinePlacementDecision.content` (and the mirrored
564
- `decision.output.content`). It previously substituted `plan_price` and
565
- `upgrade_plan_price` only.
566
-
567
- `recommended_plan_handle` / `recommended_plan_name` were already derived, by
568
- the parity-locked `resolveRecommendedPlanTokens` dispatch, and already readable
569
- via `getPersonalizationTokens()`. Nothing wrote them into rendered content, so
570
- a placement authored with `"Upgrade to {{recommended_plan_name}}"` shipped the
571
- literal braces to the end user. Observed on the CybeDefend demo, 2026-09-22.
572
-
573
- Two properties of the substitution are deliberate:
574
-
575
- | input | result |
576
- |---|---|
577
- | a token the SDK owns but cannot resolve (top-of-ladder user has no next plan) | empty string — the spec's documented empty-token convention |
578
- | a token the SDK does not own (`{{usage_percent}}`, an app-defined token) | left verbatim, so the React render lane still resolves it |
579
-
580
- `plan_name` resolves through the plan's `unique_handle`, the matching identity
581
- — the lookup used to be handed the plan *object*, which by design resolves
582
- nothing, so `{{plan_name}}` rendered raw for every user identified by
583
- `plan_handle`.
584
-
585
- **Landed in.** `0.10.8`.
586
-
587
- **Not fixed here.** `{{upgrade_plan_price}}` still renders the ANNUAL amount
588
- for a plan that has both an annual and a monthly variation. Variation
589
- preference keys off the user's billing cadence, but no supported input carries
590
- it (`billing_period` is absent from `UserContextSchema` and from the recognized
591
- `identify()` / `update()` keys), so selection falls through to the
592
- alphabetically-first variation and `<plan>_annual` always wins. Closing it
593
- needs a schema field for the cadence and a contract for which period the token
594
- reflects when the cadence is unknown; both are open on BL-0121.
595
-
596
- **Proving test.** `web-sdk/customer-side-upgrade-tokens.test.ts`.
597
-
598
- ---
599
-
600
- ## 0.10.7
601
-
602
- ### The Python and Rust ports stop filtering payloads on `status` (BL-0151)
603
-
604
- **What changed.** Payload `status` is no longer a runtime predicate in any port.
605
- Python and Rust required `status == "active"` on a placement payload before it
606
- could become a candidate, and read a `status` off each content-linked studio
607
- payload before overlaying its copy. Both now ignore it, matching the TypeScript
608
- port, which never gated on it.
609
-
610
- | | Before | After |
611
- |---|---|---|
612
- | Placement payload candidacy (Python, Rust) | required `status == "active"` | every payload, status ignored |
613
- | Content-linked copy overlay (Python, Rust) | required the studio payload's `status` to read `active` | always overlaid |
614
- | TypeScript | already ignored `status` | unchanged |
615
-
616
- **Why it mattered.** `status` is not a field of the exported Playbook.
617
- `RevTurbineConfigStudioPayload` and `RevTurbineConfigPlacementPayloadItem` carry
618
- no `status` key, unknown keys are stripped on parse, and the published JSON
619
- Schema is `additionalProperties: false` — because runtime status is *derived*
620
- control-plane side and the stored column was dropped. So `status == "active"` was
621
- false for **every** payload of a real Playbook: the Python and Rust resolvers
622
- indexed no candidates at all and returned `placement_not_found`, and their
623
- content-lookup adapters dropped every content-linked payload, keeping the inline
624
- copy where TypeScript overlaid the linked block. A config whose first payload
625
- carried a stray authored `status` also decided differently across ports.
626
-
627
- The cross-language parity gate could not see any of this: the only fixtures
628
- reaching these paths hand-write `status` on each payload — a key the schema would
629
- strip — so the ports were compared on a shape no Playbook has.
630
-
631
- **Landed in.** 0.10.7 (`server-python`, `server-rust`). No `@revt-eng/core`
632
- change rides with it: TypeScript was already correct, so there is no scaffold
633
- release and no pin bump.
634
-
635
- **Fail-closed in.** n/a — this release removes a fail-*closed* filter. Python and
636
- Rust consumers on a real Playbook go from no decision to the decision
637
- TypeScript has always returned.
638
-
639
- **Proving test.** `tests/parity/fixtures/payload_status_filter.json` — two lanes:
640
- a stray authored `status` that disagrees with drag order (payload 1 `draft`,
641
- payload 2 `active`; payload 1 must still win), and the real wire shape with no
642
- `status` anywhere plus a content-linked overlay. Mutation-checked by reinstating
643
- the Python filter, which diverges on the first call. Plus
644
- `server-python/tests/placements/test_local_resolver.py`,
645
- `test_json_content_provider.py` and `server-rust/tests/static_resolver.rs`, each
646
- covering both the stray-value and the no-key case.
647
-
648
- **Who is affected.** Every `revturbine` (PyPI) and `revturbine` (crates.io)
649
- consumer resolving placements from a schema-valid exported Playbook — that is,
650
- one produced by the control plane rather than hand-written with a `status` field.
651
- Those integrations were receiving no placement decisions at all.
652
-
653
- ---
654
-
655
- ## 0.10.6
656
-
657
- ### The trial-status PlanProvider overlay ships in the Rust crate (BL-0153)
658
-
659
- **What changed.** `revturbine::overlay_trial_status_on_plan_provider` is a new
660
- public function on the Rust crate: it maps a runtime `UserTrialStatus` onto the
661
- `trial_*` fields of a resolved PlanProviderState, the fields the placement
662
- resolver's `trial_progress` / `trial_ending` / `trial_ended` /
663
- `trial_converted` gates and milestone supersession read. Python has always done
664
- this inside its shipped package
665
- (`revturbine.sdk._overlay_trial_status_on_plan_provider`); on Rust the mapping
666
- existed only as a private helper the facade used, so a host that assembles its
667
- own provider context — including this repo's own parity runner — had to
668
- re-implement it. Additive: no existing signature changed.
669
-
670
- Porting Python's mapping faithfully corrected two Rust-only divergences in the
671
- overlay the facade applies:
672
-
673
- | `UserTrialStatus` field | Rust before | Rust now (= TS/Python) |
674
- |---|---|---|
675
- | `day_number` + `days_remaining` | not derived | `trial_days_total = day_number + days_remaining`, only when both are present |
676
- | `day_number` | written to `trial_day_number` | not written — no provider-state field reads it |
677
- | `usage_entitlement_handle` | dropped | `trial_usage_entitlement_handle` |
678
-
679
- `trial_days_total` is the input to the time-mode progress fallback in
680
- `placements::trial_gating`, so before this the fallback could never fire on the
681
- Rust port.
682
-
683
- **Landed in.** `0.10.6`.
684
-
685
- **Fail-closed in.** `0.10.6` — the parity facade-presence gate
686
- (`tests/parity/facade-surface.json`) no longer allow-lists a runner-local
687
- overlay, so a future re-implementation harness-side fails the gate.
688
-
689
- **Proving test.** `server-rust/src/sdk.rs` `overlay_maps_every_canonical_trial_field`,
690
- `trial_days_total_requires_both_halves`, `overlay_does_not_clobber_base_state_with_null`
691
- (mirroring `server-python/tests/test_trial_overlay_upsert.py`), plus the
692
- `trial_ending_days_before_end`, `trial_ended_post_expiry` and
693
- `trial_progress_milestone_supersession` parity fixtures.
694
-
695
- ---
696
-
697
- ## 0.10.5
698
-
699
- ### Trial-only local integrations get provider context (BL-0120)
700
-
701
- **What changed.** `synthesizeProviderContext()` — the fallback that builds
702
- `DomainProvider` state from `userContext` when no explicit domain provider is
703
- registered — dropped an integration's trial data whenever no commercial
704
- `plan` / `plan_handle` was also present. An app supplying **only**
705
- `initialData.trialStatus` (or `setTrialInstances()` / `setTrialStatus()` with
706
- no plan) got no provider context at all: `providers.plan` came back
707
- `undefined`, so `{{trial_days_remaining}}` (and every other `trial*` token
708
- `derivePlacementPersonalizationTokens` derives from `providers.plan`) stayed
709
- raw, unresolved, in placement copy. Reported against the CybeDefend demo
710
- (SDK 0.7.13): a `trial_ending` placement with `in_trial: true,
711
- days_remaining: 3` left `{{trial_days_remaining}}` literal in the rendered
712
- body.
713
-
714
- Two omissions, same root cause — trial was never checked alongside
715
- `plan` / `plan_handle` / `usage` / `tiers` / `experiments`:
716
-
717
- - the early-return guard (`if (!plan && !planHandle && !usage && !hasTiers
718
- && !hasExperiments) return undefined;`) treated a trial-only integration as
719
- having no signal whatsoever;
720
- - even past that guard, the returned `plan` key itself was gated on
721
- `plan || planHandle` — so the trial fields (`trialActive`,
722
- `trialDaysRemaining`, etc.) it computed were still discarded for a
723
- plan-less trial.
724
-
725
- Both gates now also check `hasTrial` (an explicit trial signal — the default
726
- untouched `{ in_trial: false }` sentinel does not count, so the omitted-signal
727
- short-circuit is otherwise unchanged for integrations that supply nothing at
728
- all). `usage`-only, `tiers`-only, and `experiments`-only integrations already
729
- threaded through correctly before this change; trial was the only signal
730
- missing from both checks.
731
-
732
- No public API change — `synthesizeProviderContext` is private, and the fix is
733
- additive: a plan-less trial integration now gets a `providers.plan` it never
734
- got before, with an empty `currentPlanHandle: ''` (no plan name/handle was
735
- ever supplied to derive one from).
736
-
737
- **Landed in** `0.10.5`. **Fail-closed in** n/a — a previously-dropped signal
738
- is now included; nothing that resolved before stops resolving.
739
-
740
- **Proving test:** `web-sdk/customer-side-trial-token-signal.test.ts` — a
741
- trial-only local integration now gets a non-empty provider context with
742
- `trialActive`/`trialDaysRemaining` set, and feeding those through
743
- `derivePlacementPersonalizationTokens` + `resolveContent` renders
744
- `{{trial_days_remaining}}` as `"3"` instead of leaving it literal. Includes
745
- the positive control (trial + plan together — unchanged), the negative
746
- control (no trial data at all — the token stays absent, never coerced to
747
- `"0"`), and a sibling check confirming usage-only integrations were already
748
- unaffected by the guard.
749
-
750
- ---
751
-
752
- ## 0.10.4
753
-
754
- ### Every payload is a selection candidate, not just the first (BL-0122)
755
-
756
- **What changed.** A placement's payloads are now *all* candidates on both
757
- selection paths, in all three ports. Previously each port considered exactly one
758
- payload per placement — TypeScript took `payloads[0]`, Python and Rust the first
759
- payload with `status == "active"` — so payloads 2+ were never candidates and
760
- their `target.segment_chips` could not be evaluated at all.
761
-
762
- | | Before | After |
763
- |---|---|---|
764
- | Candidacy | one payload per placement | every payload (Python/Rust: every `active` payload) |
765
- | `target.segment_chips` on payloads 2+ | never evaluated | evaluated per payload |
766
- | Drag precedence | a pre-filter — it chose the only candidate | a tiebreaker among the payloads the user **matches** |
767
- | Direct lookup by name/id | mapped a name to one payload | maps a name to every payload, gated individually, first eligible wins |
768
-
769
- The contract this restores is
770
- [`placement-prioritization.md`](https://github.com/revt-eng/revturbine-devkit/blob/main/docs/specs/scaffold/placement-prioritization.md)
771
- §1 stage 3 — "Targeting — the user's plan and segment match **a payload**" — with
772
- §4 and Appendix D scoping drag precedence to payloads that are equally eligible.
773
-
774
- **Why it mattered.** A placement holding an admin payload and a member payload
775
- gave every member either the admin copy (when payload 1 was unchipped) or
776
- nothing at all (when payload 1 was chipped to admins). An integration hit the
777
- second shape on 0.7.13 and concluded config-driven segmentation was inert. Plan
778
- 233 TASK-7 had already made the chip predicate real — but the predicate was only
779
- ever asked about one payload.
780
-
781
- **Landed in.** 0.10.4 (all three ports, plus `@revt-eng/core` 0.1.326).
782
-
783
- **Fail-closed in.** 0.10.4 — the same release. There is no tolerance window:
784
- a user who matches no payload now receives `visible: false` with
785
- `segment_target_mismatch` rather than payload 1's content.
786
-
787
- **Proving test.** `tests/parity/fixtures/segment_chip_payload_selection.json`
788
- (cross-language), plus `local-resolver-payload-selection.test.ts`,
789
- `server-python/tests/placements/test_payload_selection.py` and
790
- `server-rust/tests/payload_selection.rs` — each exercising **both** the slot and
791
- the direct-lookup path, because gating one and not the other is exactly the back
792
- door plans 138 and 233 each had to close.
793
-
794
- **Who is affected.** Any Playbook authoring more than one payload on a
795
- placement where payload 1 is not the payload a given user matches. Those users
796
- now see the payload their chips select — which is the fix, and is a live
797
- behaviour change for such configs. Single-payload placements are unaffected.
798
-
799
- ---
800
-
801
- ## 0.10.3
802
-
803
- ### The Rust port gains the decision surfaces Python already shipped (BL-0145)
804
-
805
- **What changed.** `RevTurbineCustomerSdk` on the Rust port (`revturbine`,
806
- crates.io) now exposes the same decision surfaces the Python port exposes.
807
- Nothing on the TypeScript or Python side changes; this is a Rust-only,
808
- purely additive release.
809
-
810
- | Added | Shape |
811
- |---|---|
812
- | `RevTurbineCustomerSdk::can(handle, context)` | The advertised alias of `check_entitlement`, matching the scaffold SDK function surface (canonical `checkEntitlement`, alias `can`). |
813
- | `RevTurbineCustomerSdk::get_eligible_plans()` | `Vec<EligiblePlan>` — public, segment-eligible plan variations. |
814
- | `RevTurbineCustomerSdk::get_eligible_addons()` | `Vec<EligibleAddon>` — the add-on twin. |
815
- | `RevTurbineCustomerSdk::evaluate_trial_status(instances, now_iso, base_plan_handle, usage_balances)` | `TrialEvaluation`, reading `free_trial_rules` / `reverse_trial_rules` from the constructed Playbook. |
816
- | `revturbine::format_currency_minor_units(amount, currency, locale)` | Free function, because Python exposes it as a module function. |
817
- | `revturbine::plans` | New module carrying the catalog eligibility port and the formatter. |
818
- | `UserContext::segment_ids` | Pre-resolved segment ids the catalog methods match against. |
819
-
820
- `UserContext` gains a field but derives `Default`, so existing construction
821
- with `..Default::default()` keeps compiling.
822
-
823
- **Why it was invisible.** The cross-language parity gate was green on the
824
- catalog and currency scenarios only because the Rust parity runner
825
- *reimplemented* `eligible_catalog` and `format_currency_minor_units` inline
826
- rather than calling the crate — so the comparison never touched shipped code,
827
- and the crate could lack the capability entirely without a single fixture
828
- going red. The runner now calls `revturbine::plans`, and the inline copies are
829
- deleted; all 102 comparisons stay byte-identical.
830
-
831
- ---
832
-
833
- ## 0.10.2
834
-
835
- ### Local mode resolves placements by slot id (BL-0119)
836
-
837
- **What changed.** In `local_only` mode the browser SDK now resolves a placement
838
- request against the Playbook's authored `placement_slots[]` registry.
839
- `getPlacement({ slotId })` — with or without a `componentType` — previously
840
- returned `null` for a slot the Playbook declared, and a controller/`usePlacement`
841
- mount of that slot resolved to `placement_not_found`. Looking the same placement
842
- up by **name** worked, which is what made this look like a config problem rather
843
- than an SDK one.
844
-
845
- Two things were missing, and both are now in place:
846
-
847
- - **`registerSurfaceSlot()` adopts the declared template.** `surface_template_ids`
848
- on the registered record is what puts the shared resolver on its slot branch,
849
- where `trigger.slot_id` is matched. Without it the resolver fell back to direct
850
- lookup, which is keyed by placement **name**/id and therefore can never match a
851
- slot id — hence `placement_not_found`. An id passed explicitly at the call site
852
- still wins; the mounting code knows what it renders.
853
- - **`getPlacement()` falls through to the slot registry.** The local placement
854
- cache only holds slots a decision has already run through, so a cold lookup
855
- found nothing and stopped. It now derives the record from `placement_slots`,
856
- matching on `id` and falling back to `surface_type` when only a component type
857
- is given — the same derivation scaffold's headless
858
- `LocalRuntime.slotRecordForConfig` has always performed. Registration on this
859
- path is local only: reading a placement never writes a surface slot back to the
860
- control plane.
861
-
862
- This closes a browser-versus-headless divergence: the same Playbook decided
863
- differently depending on which runtime read it. No public API change — no
864
- signature moved, and nothing new is asked of the caller. A slot id that no
865
- `placement_slots` entry declares still resolves to `null`, unchanged.
866
-
867
- **Landed in** `0.10.2`. **Fail-closed in** n/a — a previously-`null` lookup now
868
- returns the placement the Playbook authored.
869
-
870
- **Proving test:** `web-sdk/local-slot-id-lookup.test.ts` — by-slot-id and
871
- by-slot-id-plus-component-type both resolve, the controller path no longer
872
- reports `placement_not_found`, an undeclared slot id still returns `null` and
873
- gains no invented template, an explicit `surfaceTemplateIds` still wins, and the
874
- by-name lookup that always worked keeps working (the positive control).
875
-
876
- ---
877
-
878
- ## 0.10.1
879
-
880
- ### Treatment interactions carry the identified `account_id`
881
-
882
- **What changed.** Every treatment interaction the SDK sends to
883
- `/api/events/interactions` — the impression that writes a
884
- `placement_presentations` row, plus dismiss / click / conversion — now carries
885
- `account_id`, taken from the account the integration identified
886
- (`identify(userId, { account_id })` / `setUserContext`). It was never sent at
887
- all, so the ingest route's `account_id ?? user_id` fallback stamped a **user**
888
- id into an **account** column. `placement_presentations.account_id` is a join
889
- key, not a label: `monetization_funnel` matches it against account ids from
890
- `events_clickstream` / `events_billing`, and every experiment summary pipe
891
- reads it when `analysis_unit='account'`. Those joins therefore matched only
892
- where a user id happened to equal an account id — and an account-grain
893
- experiment readout returned the user-grain n while looking perfectly valid.
894
-
895
- Two details make it a usable key. The value is PII-redacted **identically to
896
- `/api/track`**, so an email-shaped account id becomes the same hash in
897
- `placement_presentations` and in `events_clickstream` and the funnel actually
898
- joins. And when no account was identified the field is **omitted**, never set
899
- to the user id — the route keeps its own fallback for SDKs that have not
900
- upgraded, but a wrong join key is worse than a missing one.
901
-
902
- No public API change: nothing new is asked of the caller, and
903
- `RevTurbineTreatmentInteractionInput` is unchanged. Integrations that already
904
- pass `account_id` to `identify()` get correct account-grain analytics with no
905
- code change; integrations that never identify an account are unaffected on the
906
- wire and see the same route-side fallback as before.
907
-
908
- **Landed in** `0.10.1`. **Fail-closed in** n/a — additive; nothing old is
909
- rejected.
910
-
911
- **Proving test:** `web-sdk/interaction-wire-contract.test.ts` — "the account
912
- identity the analytics joins key on": the identified account reaches the wire
913
- distinct from `user_id`, an unidentified one is omitted rather than copied from
914
- `user_id`, an email-shaped account id matches the `/api/track` lane
915
- byte-for-byte, and a re-queued batch keeps the account that was acting when
916
- each interaction happened.
917
-
918
- ---
919
-
920
- ## 0.10.0
921
-
922
- ### `publicKey` is the browser credential; `apiKey` is the server key
923
-
924
- **What changed.** The browser init option for the ingest (public) key is
925
- `publicKey` — on `initRevTurbine`, `<RevTurbineProvider options>`, and the
926
- headless init alike. Every browser bearer (ingest, launched-Playbook delivery,
927
- user context, branding, trial status) uses that one key. `apiKey` now means
928
- one thing everywhere: the secret **server key**, as it always did on
929
- `@revturbine/sdk/server` — pass it from backend code only (a route handler
930
- using `@revturbine/sdk/headless` keeps using `apiKey`). The name states what the
931
- credential is at the call site: a security scanner or an agent that meets
932
- `publicKey` in a bundle knows it belongs there, where `apiKey` reads as a leak.
933
-
934
- `apiKey` and `ingestPublicKey` are still accepted on a browser init as aliases
935
- of `publicKey` for one minor (precedence: `publicKey`, then `ingestPublicKey`,
936
- then `apiKey`). Using an alias without `publicKey` in a browser logs a one-time
937
- development warning naming `publicKey`; production builds are silent. On the
938
- browser `RevTurbineInitOptions` type, `apiKey` is now optional (it was required)
939
- and `ingestPublicKey` is `@deprecated`. Keyless local-only init is unchanged,
940
- and the plan 95 anonymous `sdk_init` beacon still fires only when no public key
941
- (`publicKey` or `ingestPublicKey`) was supplied.
942
-
943
- Ruling: Kent, 2026-09-21, devkit PR #808 (closed).
944
-
945
- **Landed in** `0.10.0`. **Fail-closed in** `0.11.0` — `ingestPublicKey` was
946
- removed from the options type and `apiKey` stopped being read as a browser
947
- credential (BL-0113); see the `0.11.0` entry above.
948
-
949
- **Proving test:** `web-sdk/public-key-option.test.ts` — precedence, the single
950
- bearer across ingest and control-plane fetches, the browser-only warning, and
951
- the keyless local-only init. Type-level: `web-sdk/init-options-exactness.test-d.ts`
952
- accepts `publicKey` and rejects the `publickey` casing typo.
953
-
954
- ### `endpoint` and `mode` are optional on `initRevTurbine` and `<RevTurbineProvider>`
955
-
956
- **What changed.** `RevTurbineInitOptions.endpoint` defaults to
957
- `https://revturbine.com/app` (`DEFAULT_HOSTED_ENDPOINT`, exported) and
958
- `RevTurbineInitOptions.mode` defaults to `'snippet'` (`DEFAULT_SDK_MODE`,
959
- exported); `<RevTurbineProvider>` passes `'react'` when the app does not set it.
960
- Both were required; `mode` only ever labelled telemetry (`page_view.mode` and a
961
- diagnostics message), and every hosted integration used the same endpoint.
962
- Existing calls that pass them are unchanged. Local mode is unchanged (its
963
- placeholder defaults already applied). Additive, no version bump required.
964
-
965
- **Landed in** `0.10.0`. **Fail-closed in** n/a — nothing old is rejected.
966
-
967
- **Proving test:** `web-sdk/public-init.test.ts` — "defaults endpoint and mode
968
- for a hosted init that omits them".
969
-
970
- ### Reason-code compatibility verification
971
-
972
- Added a reviewed baseline for 27 entitlement and 21 placement reason values,
973
- checked against live SDK/core fixtures. Removal/rename controls exercise every
974
- protected value, and baseline changes require this changelog to change through
975
- the existing public-API gate. Custom provider reasons remain extensible and
976
- free-form diagnostics are excluded. This adds verification only; no runtime
977
- reason values or package versions change.
978
-
979
- Proving tests: `web-sdk/reason-contract.test.ts` and
980
- `web-sdk/reason-contract-policy.test.ts`. The intentional-change procedure is
981
- in `docs/reason-code-contract.md`.
982
-
983
- ## 0.9.3
984
-
985
- ### Fixed
986
-
987
- - Placement explanations use the same published segment-eligibility helper as
988
- decisions, keeping OR-within-segments and AND-with-plan behavior aligned.
989
- The explanation/decision matrix covers local and provider-backed Playbooks,
990
- unknown or missing segments, exact handle matching and plan-filter combinations.
991
-
992
- ## 0.9.2
993
-
994
- ### Public initializer user context
995
-
996
- `await initRevTurbine({ user: { id, ...context }, ...options })` from
997
- `@revturbine/sdk` or `@revturbine/sdk/headless` now passes `id` only as the
998
- identity argument. Supported user context retains its existing behavior without
999
- the spurious unrecognized-context-key warning for `id`; genuinely unknown keys
1000
- still warn. Public quick starts now show the awaited `SdkSession` and
1001
- `session.sdk.getBranding()`. The separate synchronous core initializer is unchanged.
1002
-
1003
- Proving tests: `web-sdk/public-init.test.ts` and the installed public-package
1004
- runtime/type examples in `web-sdk/scripts/check-public-diagnostics.mjs`.
1005
-
1006
- ## 0.9.1
1007
-
1008
- ### Public changelog distribution
1009
-
1010
- The maintained changelog is now included as `CHANGELOG.md` in the npm package
1011
- and mirrored at the root of the public SDK repository. The docs Reference
1012
- navigation links directly to that authoritative public file. The independent,
1013
- hidden docs copy has been retired. The pre-0.3.0 history gap below remains;
1014
- this release does not reconstruct it. No SDK runtime behavior changed.
1015
-
1016
- ## 0.9.0
1017
-
1018
- ### Placement eligibility follows current context after conversion
1019
-
1020
- - **What changed** — Conversion remains an analytics event and no longer retires
1021
- or temporarily suppresses a placement. Legacy conversion-owned browser state
1022
- is ignored. Supply the updated UserContext after a plan change; Playbook
1023
- targeting determines whether the offer still applies.
1024
- - **What changed** — Fixed and Access Gate placements bypass dismissal, reminder
1025
- and bare-click cooldowns. Dismiss and snooze close the current display; the
1026
- next explicit trigger, refresh or mount evaluates again. This uses the resolved
1027
- placement category, including when another category renders inside a Fixed slot.
1028
- Other categories retain authored/default windows, and explicit system
1029
- suppression and entitlement denial remain effective.
1030
- - **Landed in** — `0.9.0` across JavaScript, Python and Rust.
1031
- - **Fail-closed in** — Not applicable; no input shape was removed. Rust callers
1032
- constructing `InteractionState` literals should supply the new optional
1033
- `explicit_suppressed_until` field or use `..Default::default()`.
1034
- - **Proving test** — `web-sdk/conversion-eligibility.test.ts`,
1035
- `web-sdk/placements/FixedSurfaceSlot.dismissal.test.tsx`, and the shared
1036
- `placement_category_interactions` parity fixture.
1037
-
1038
- ## 0.8.9
1039
-
1040
- ### JavaScript: development diagnostics survive package minification
1041
-
1042
- - **What changed** — The SDK package preserves the environment check until the
1043
- consuming application builds it. Previously the minified package baked in
1044
- production mode, hiding the React initialization-failure banner, app-theme
1045
- override warning and legacy-theme warning even in development applications.
1046
- Production applications still suppress those development diagnostics;
1047
- `initStatus` and required initialization-error reporting remain available.
1048
- The existing fallback for browsers without `process` is preserved.
1049
- - **Landed in** — `0.8.9`.
1050
- - **Fail-closed in** — Not applicable; no decision or initialization policy changes.
1051
- - **Proving test** — `web-sdk/scripts/check-public-diagnostics.mjs` installs the
1052
- public tarball and runs Chromium against separate development, production
1053
- and environment-preserving consumer bundles. The PR release-build gate
1054
- retains the tarball, inventory, SHA-256 and observed diagnostics.
1055
-
1056
- Python and Rust receive the matching version bump; their behavior is unchanged.
1057
-
1058
- ## 0.8.8
1059
-
1060
- Behavioural fixes in Python; a new decision surface in Rust.
1061
-
1062
- ### Python: numeric coercion unified onto JS Number() semantics
1063
-
1064
- - **What changed** — `parse_numberish`'s string branch used Python
1065
- `float()`, which accepts forms JS `Number()` rejects (underscore
1066
- separators: `"1_000"` scored 1000 here, NaN->0 in TypeScript — a
1067
- candidate-flipping divergence in every selection scorer) and rejects
1068
- forms JS accepts (`"0x10"` is 16). The one JS-semantics parser from the
1069
- plan-233 segments port is promoted to `helpers.js_number` and every
1070
- coercion site shares it. Ripple alignments: `Number("")` is 0 (the old
1071
- is-None assertion pinned the float() behaviour), a whitespace-only
1072
- `template_version` resolves to `"0"` exactly as TS does, and
1073
- `superseded_versions` no longer fabricates a `"0"` entry from an empty
1074
- string (mirrors TS's trim-then-filter).
1075
-
1076
- ### Rust: the candidate-selection layer, ported
1077
-
1078
- - **What changed** — `resolve_local_placement_from_candidates` plus the
1079
- full helper set (category buckets, plan-53 two-stage tier-3 urgency,
1080
- milestone supersession, category conflict suppression, server-order
1081
- dominance) now exists in Rust (`placements/selection.rs`). Until now the
1082
- Rust port selected by entry order only; competing-category selections
1083
- had no third side, which is why plan 234 TASK-15's fixture was deferred.
1084
- - **Proving test** — parity fixture `placement_selection_priority`
1085
- (10 calls: cross-tier dominance, both tier-3 stages including a
1086
- class-vs-proximity disagreement, retention/conversion tie, server_order
1087
- skipping conflict suppression, fixed_only, milestone supersession, both
1088
- REQ-5 coercion lanes, output_id tiebreak), byte-identical ts/py/rs on
1089
- its first regen, plus mirrored port-local suites in Python and Rust.
1090
-
1091
- ## 0.8.7
1092
-
1093
- No runtime behaviour change in any port; the release carries new parity
1094
- locks and one serde derive.
1095
-
1096
- ### All ports: trial-status derivation and allocation scoping parity-locked
1097
-
1098
- - **What changed** — two decision surfaces every runtime already evaluated
1099
- gained their first cross-language locks. `evaluateTrialStatus` (the
1100
- free/reverse trial-rule derivation): the TS golden vectors mirrored the
1101
- Python unit suite by hand-maintained convention, and Rust was never in
1102
- the mirror at all; the `trial_status_evaluation` fixture makes the
1103
- agreement a byte-checked property. `computeConsumedPercent` (the spec
1104
- 3.4 Pooling allocation-scoped grant selection): reachable from no
1105
- placement fixture because the static provider has no grants lane; the
1106
- `threshold_allocation_scoping` fixture drives every allocation label
1107
- against a hierarchy whose levels carry DIFFERENT counters.
1108
- - **A wire divergence caught on first regen** — the reverse-trial grants
1109
- set serialized three different ways (TS `JSON.stringify(Set)` = `{}`
1110
- with the contents silently lost; Python's canonicalizer dropped the key;
1111
- Rust emitted the honest list). The runners now own the wire form: a
1112
- sorted array on every side.
1113
- - **Fail-closed in** — no evaluator changed; `TrialEvaluation` gained a
1114
- `Serialize` derive in Rust (additive).
1115
- - **Proving test** — parity fixtures `trial_status_evaluation` (6 calls;
1116
- the usage-lane call was sharpened after the first cut put
1117
- `trial_limit_type` on the RULE and every side agreed on `time` - the
1118
- non-discriminating shape REQ-3 forbids; it rides the INSTANCE) and
1119
- `threshold_allocation_scoping` (9 calls).
1120
-
1121
- ## 0.8.6
1122
-
1123
- One capability lands in the two ports; TypeScript is unchanged and ships this
1124
- version for lockstep alone.
1125
-
1126
- ### Python + Rust: targeting-state derivation ported (buildTargetingState)
1127
-
1128
- - **What changed** — `LocalRuntime.build_targeting_state` raised
1129
- NotImplementedError in Python (a REQ-14 deferral) and had no Rust port at
1130
- all, so the derivation from a raw user context to the traits segment
1131
- evaluation consumes existed only in TypeScript: segment parity proved
1132
- "agrees given the same traits", never "agrees given the same user
1133
- context". Both ports now carry the pure derivation
1134
- (`core.user_context.build_targeting_state` / `user_context.rs`), byte-
1135
- locked against the TS canonical.
1136
- - **Also aligned red-first** — Python's dormant
1137
- `configured_plan_name_from_exported_config` (no callers until now) still
1138
- carried pre-plan-120 semantics: id matching, plan-object resolution, and
1139
- a `_handle` suffix fallback. Plans resolve by `unique_handle` alone; the
1140
- drift-pinning tests were corrected with the alignment.
1141
- - **Fail-closed in** — identity rules are shadow-proof by construction:
1142
- `plan_handle` is a reserved trait a `custom.plan_handle` can never
1143
- impersonate, and no identity means the reserved trait is REMOVED and the
1144
- effective plan omitted.
1145
- - **Proving test** — parity fixture `targeting_state_derivation` (6 calls:
1146
- identity precedence, shadow-proofing, plan-OBJECT fallback, the id that
1147
- must resolve nothing, the no-identity removal, override-wins usage with a
1148
- legacy bare-number entry), byte-identical ts/py/rs, plus the corrected
1149
- unit matrices in both ports.
1150
-
1151
- ## 0.8.5
1152
-
1153
- One behavioural alignment, Python only. TypeScript and Rust are unchanged and
1154
- ship this version for lockstep alone.
1155
-
1156
- ### Python: category buckets and tier-3 urgency aligned to the plan-53 model
1157
-
1158
- - **What changed** — this port's `category_bucket` carried the pre-plan-53
1159
- map for months (trial=3, retention=5 where the canonical folds trial into
1160
- priority tier 3 as bucket 2 and retention into the single discretionary
1161
- bucket 4); its docstring cited source lines that had moved AND changed
1162
- upstream. The map feeds the selection comparator, so a trial and a usage
1163
- placement competing for one surface ordered STRICTLY here (usage always
1164
- first) while the canonical ties them and applies the plan-53 two-stage
1165
- urgency model — which this port also lacked. `tier3_class` is now ported
1166
- and staged ahead of proximity in `resolve_local_placement_from_candidates`,
1167
- the map matches the canonical, and the system-caps exemption boundary moved
1168
- from the compensating `<= 3` to the canonical `<= 2` (the exempted category
1169
- set is unchanged).
1170
- - **Landed in** — `helpers.category_bucket` / `helpers.tier3_class`, the
1171
- comparator in `placement_decision.py`, and the caps exemption boundary.
1172
- - **Fail-closed in** — no gate changes; this is ordering. Exemptions and
1173
- discretionary classification are set-identical before and after.
1174
- - **Proving test** — `tests/placements/test_selection_tier3.py` (class
1175
- ordering with class-vs-proximity DISAGREEMENT cases; mutation-checked:
1176
- disabling the tier3 stage fails exactly the two stage-1 tests), plus the
1177
- bucket-map matrix in `test_helpers.py` whose old strict `usage < trial`
1178
- invariant was pinning the drift. The cross-language fixture for this
1179
- surface lands with the Rust selection-layer port (plan 234 TASK-8).
1180
-
1181
- ## 0.8.4
1182
-
1183
- One behavioural alignment, Python only. TypeScript and Rust are unchanged and
1184
- ship this version for lockstep alone.
1185
-
1186
- ### Python: cap/cooldown maths consolidated onto the shared primitive
1187
-
1188
- - **What changed** — `placement_decision.py` and `cap_enforcer.py` carried
1189
- their own copies of the cap-window and cooldown arithmetic that TypeScript
1190
- consolidated onto `evaluateCaps` (plan 233 TASK-15 / scaffold #351). Both
1191
- now delegate to the new `cap_rules.evaluate_caps` port. Reading the bodies
1192
- side by side surfaced two silent divergences, both aligned to the TS
1193
- canonical: Fixed / Access-Gate categories are now exempt from per-payload
1194
- caps in `check_placement_caps` (TS has exempted them since plan 167; this
1195
- port capped them), and a cap deny now persists the UNFILTERED state — the
1196
- old bodies trimmed `seen_at` to the tripping window (one comment claimed
1197
- "Mirrors TS"; TS does no such trim), silently shrinking future week/month
1198
- windows.
1199
- - **Landed in** — `core/placements/cap_rules.py` (the primitive),
1200
- `check_placement_caps` / `check_system_presentation_caps` /
1201
- `CapEnforcer.enforce` (the delegations).
1202
- - **Fail-closed in** — verdicts only ever tighten or stay: the exemption
1203
- affects categories that are user-prompted by design, and the state
1204
- alignment preserves more history, never less.
1205
- - **Proving test** — `tests/placements/test_cap_delegation.py`: a 12-case
1206
- characterization matrix green before AND after the refactor, the two
1207
- aligned behaviours run RED against the pre-refactor body, and a mutation
1208
- of the shared window arithmetic fails four tests across both entry points
1209
- (the shared maths is reached, not shadowed).
1210
-
1211
- ## 0.8.3
1212
-
1213
- One behavioural fix, Rust only. TypeScript and Python are unchanged and ship
1214
- this version for lockstep alone.
1215
-
1216
- ### Rust: a confirmed conversion did not retire the placement
1217
-
1218
- - **What changed** — the Rust port carried the full ImpressionHistory
1219
- machinery (`record_conversion`, `is_hidden_sync`, the retired cache) since
1220
- the port landed, but its placement resolver never consulted it. A user who
1221
- completed a placement's CTA — a confirmed conversion — kept seeing that
1222
- placement in this port alone, while TypeScript core
1223
- (`local-resolver.ts:610-612`, `:726`) and Python both hide it permanently.
1224
- This is the defect class plan 167's unshipped follow-up became as a
1225
- customer P1: paying customers shown the upsell they already bought.
1226
- - **Landed in** — the retirement gate is wired at both decision lanes: the
1227
- candidate filter (before the trigger gates, mirroring the TS order) and
1228
- the direct-lookup path, which now answers `placement_retired`, keyed by
1229
- the output's `rule_id` exactly as TS core and Python key it.
1230
- - **Fail-closed in** — the gate only ever hides; an empty or cold history
1231
- changes nothing, and a placement with no recorded conversion is untouched.
1232
- - **Proving test** — parity fixture `placement_conversion_retires`
1233
- (visible -> convert -> `placement_retired`, with an untouched control
1234
- staying visible, byte-identical across ts/py/rs; mutation-checked: making
1235
- the Rust gate inert diverges the snapshot) and
1236
- `server-rust/tests/local_runtime.rs`
1237
- (`a_confirmed_conversion_permanently_retires_the_placement`).
1238
-
1239
- ## 0.8.2
1240
-
1241
- One behavioural fix, Rust only. TypeScript and Python are unchanged and ship
1242
- this version for lockstep alone.
1243
-
1244
- ### Rust: provider-path rule selection was source-order, not most-permissive
1245
-
1246
- - **What changed** — on the provider path (`check_entitlement` with a rules
1247
- provider, the path a static config registers), the Rust port's §2.6.5
1248
- most-permissive selection scored each rule snapshot's root object instead
1249
- of its `fields` sub-object, where the adapter actually nests the score keys
1250
- (`limit_value`, `allowance`, `included_count`, `enabled`). Every rule
1251
- scored 0, so the deterministic tie-break — earliest in source order —
1252
- silently decided instead. Concretely: an entitlement carrying both a seat
1253
- rule (listed first) and a `usage_limit` rule at limit 2 returned **allowed**
1254
- at `used: 3` where TypeScript and Python return limited
1255
- (`usage_limit_reached`). Whether you were affected depends on rule order in
1256
- your Playbook, which is what made this silent. The ExportedConfig fallback
1257
- path (`derive_local_entitlement_from_configured_rules`) was NOT affected —
1258
- it passes the merged type-fields bag and scored correctly.
1259
- - **Landed in** — `0.8.2`.
1260
- - **Fail-closed in** — `0.8.2` (same release; no tolerated window).
1261
- - **Proving test** — parity fixture `entitlement_rule_seat_included_count`
1262
- (byte-locked ts ≡ py ≡ rs; diverged on exactly the shadowed-limit call until
1263
- the fix) and `most_permissive_scores_the_fields_sub_object_not_the_snapshot_root`
1264
- (Rust unit, mutation-checked). The pre-existing Rust unit test had authored
1265
- its score key at the snapshot root — a shape the adapter never emits — and
1266
- passed over the bug; it now uses the production shape.
1267
-
1268
- ---
1269
-
1270
- ## 0.8.1
1271
-
1272
- One behavioural fix, Python and Rust only. The TypeScript SDK is unchanged and
1273
- ships this version for lockstep alone.
1274
-
1275
- ### Python/Rust: cross-dimension segment AND no longer degrades to flat OR
1276
-
1277
- - **What changed** — the config evaluator's segment→dimension lookup was keyed
1278
- by the segment's `id` in the Python and Rust ports, where the TypeScript
1279
- canonical keys it by `handle`. Rule `segment_ids` are handle-valued, so on
1280
- any Playbook whose segment ids differ from their handles — every real
1281
- export — the ports' lookups all missed, every rule segment collapsed into
1282
- the uncategorised bucket, and a rule spanning two dimensions (which must
1283
- require BOTH, intra-dimension OR + cross-dimension AND) matched on EITHER.
1284
- Concretely: an entitlement rule scoped to `emea` (region) AND `admins`
1285
- (role) **granted** a user who was only in `emea`. It now denies, matching
1286
- the TypeScript SDK. If your integration passes a non-empty user segment set
1287
- to `derive_local_entitlement_from_configured_rules` and relies on a
1288
- cross-dimension rule, users who matched only one dimension lose the grant
1289
- on upgrade — that is the fix working. The built-in runtime fallback path
1290
- passes an empty segment set and is unaffected.
1291
- - **Landed in** — `0.8.1`.
1292
- - **Fail-closed in** — `0.8.1` (same release; no tolerated window).
1293
- - **Proving test** — parity fixtures `entitlement_segment_dimensions` /
1294
- `entitlement_segment_no_dim_bucket` (byte-locked TS ≡ Python ≡ Rust; the
1295
- regen diverged on exactly the cross-dimension calls until the fix), plus
1296
- `TestSegmentDimensionLookup` (Python) and `cross_dimension_and_*` (Rust).
1297
-
1298
- ## 0.8.0
1299
-
1300
- Plan 233 remediated four escalated defect families, and this is the release that
1301
- delivers them. Everything below sat merged on `main` and unpublished for the
1302
- length of that plan — 20 commits, twelve breaking changes — because the version
1303
- never moved off `0.7.13`.
1304
-
1305
- **Read this section before upgrading from `0.7.x`.** Three of these change live
1306
- behaviour rather than only types: segment-targeted payloads stop showing to
1307
- users outside the segment, `sdk.dismiss()` starts writing suppression where it
1308
- previously did nothing, and a converted placement stops coming back. Each is the
1309
- fix working, and each is visible to your users the moment you upgrade.
1310
-
1311
- ### The `tenantId` init option is now the authority over a Playbook's `tenant_id`
1312
-
1313
- - **What changed** — the Playbook's `tenant_id` used to win, and the init option
1314
- was a fallback applied only to legacy artifacts. That combination killed every
1315
- integration that served a canonical Playbook *and* passed the tenant at init:
1316
- the artifact had no `tenant_id` to win with, and the option was withheld
1317
- because the artifact was canonical, so init threw. The init option now wins.
1318
- The artifact's `tenant_id` is a **guard** — it exists so the CLI can refuse to
1319
- upload a config to the wrong tenant — and on a mismatch the SDK warns, naming
1320
- both values, and proceeds. It never fails init.
1321
- - **Landed in** — `0.8.0`
1322
- - **Fail-closed in** — n/a. This change makes init *succeed* where it used to
1323
- throw. The old behaviour was the failure.
1324
- - **Proving test** — `web-sdk/config-target-precedence-init.test.ts`
1325
-
1326
- ### Unrecognized and missing init options are compile errors
1327
-
1328
- - **What changed** — init options are exact-checked. A typo (`apikey` for
1329
- `apiKey`) or a stale option is a type error rather than a value the SDK
1330
- silently ignores. This catches the case TypeScript's excess-property check
1331
- never saw: options assembled in a variable, or in an un-annotated `useMemo`,
1332
- which is exactly how React integrations build them.
1333
- - **Landed in** — `0.8.0`
1334
- - **Fail-closed in** — same. Unknown keys were ignored at runtime before and
1335
- still are; what changed is that you cannot write them.
1336
- - **Proving test** — `web-sdk/init-options-exactness.test-d.ts`
1337
-
1338
- ### A truncated Playbook is a compile error at the `localRuntime` boundary
1339
-
1340
- - **What changed** — `UnvalidatedConfigArtifact` was `Record<string, unknown>`
1341
- and now structurally requires the body arrays (`plans`, `entitlements`,
1342
- `entitlement_rules`, `segments`, `content_ui_paths`). A Playbook missing one is
1343
- now caught by `tsc` instead of throwing at init.
1344
- - **Who this breaks** — only a caller who **explicitly annotated** their artifact
1345
- `Record<string, unknown>`; that must now be narrowed. A JSON module import and
1346
- a fetched artifact (`await res.json()`, typed `any`) both still compile with no
1347
- cast.
1348
- - **Landed in** — `0.8.0`
1349
- - **Fail-closed in** — same version, at compile time. Runtime validation was
1350
- already fail-closed and is unchanged.
1351
- - **Proving test** — `web-sdk/playbook-structural.test-d.ts`
1352
-
1353
- ### Init failure is observable without an SDK instance
1354
-
1355
- - **What changed** — when init fails the provider used to log and render children
1356
- anyway, so the SDK simply never started and every diagnostic probe was
1357
- unreachable *in precisely the failure mode that matters most*. `initStatus` is
1358
- now on the React context and is readable when the SDK instance is `null`, with
1359
- a non-empty `remediation` string on every init-path error.
1360
- - **Landed in** — `0.8.0`
1361
- - **Fail-closed in** — same.
1362
- - **Proving test** — `web-sdk/react/RevTurbineProvider.init-status.test.tsx`
1363
-
1364
- ### The rendered theme resolves through the branding ladder
1365
-
1366
- - **What changed** — `RevTurbineProvider` read `playbook?.theme` directly, so the
1367
- `branding` init option never reached `useRevTurbineTheme()`. `getBranding()`
1368
- and the rendered theme disagreed; that divergence *was* the defect. Both now
1369
- resolve from the same rung.
1370
- - **Landed in** — `0.8.0`
1371
- - **Fail-closed in** — same.
1372
- - **Proving test** — `web-sdk/react/RevTurbineProvider.branding-ladder.test.tsx`
1373
-
1374
- ### `RevTurbineThemeProvider` merges partial themes, and an app-mounted one wins
1375
-
1376
- - **What changed** — a partial theme replaced the defaults wholesale instead of
1377
- merging, so supplying one token dropped every other. Partial themes now merge.
1378
- Separately: if your app mounts its own `RevTurbineThemeProvider`, it wins over
1379
- the SDK's internal default, and a conflict warns in development.
1380
- - **Landed in** — `0.8.0`
1381
- - **Fail-closed in** — same.
1382
- - **Proving test** — `web-sdk/theme/ThemeContext.partial.test.tsx`,
1383
- `web-sdk/react/RevTurbineProvider.theme-nesting.test.tsx`
1384
-
1385
- ### Light/dark schemes no longer require re-initializing the SDK
1386
-
1387
- - **What changed** — additive. A `colorScheme` option and `setColorScheme()` swap
1388
- palettes while keeping SDK instance identity stable. The previous workaround
1389
- was re-initializing the SDK, which discarded its state.
1390
- - **Landed in** — `0.8.0`
1391
- - **Fail-closed in** — n/a, additive.
1392
- - **Proving test** — `web-sdk/react/RevTurbineProvider.color-scheme.test.tsx`
1393
-
1394
- ### `segment_chips` are enforced in the decision path
1395
-
1396
- - **What changed** — payload `segment_chips` were evaluated by
1397
- `explainPlacementDecision` but **not** by the decision path itself, so the
1398
- probe and the decision disagreed and a segment-targeted payload could show to a
1399
- user outside the segment. Both now call one shared helper, in every port.
1400
- - **Who this breaks** — an integration relying, knowingly or not, on
1401
- segment-targeted payloads showing to everyone will now see them correctly
1402
- restricted. **There is no grace period**: this was ruled a bug, not a behaviour
1403
- change.
1404
- - **Landed in** — `0.8.0`
1405
- - **Fail-closed in** — same version, and in all three runtimes together.
1406
- - **Proving test** — `tests/parity/fixtures/segment_chip_targeting.json` and
1407
- `tests/parity/fixtures/segment_evaluation.json`, run byte-identical across
1408
- TypeScript, Python and Rust.
1409
-
1410
- ### Dismissal actually suppresses, at the authored window
1411
-
1412
- - **What changed** — two defects. The interaction-state **read** omitted
1413
- `treatmentId` while the **write** included it, and the controllers always
1414
- populate it, so the keys could never match and dismissal suppression was dead
1415
- for every React integration. And the window was a hardcoded 24-hour literal
1416
- passed explicitly at every call site, which made both the authored
1417
- `cooldown_after_dismiss_days` and the 7-day default unreachable. The window now
1418
- resolves: explicit caller value → **authored config** → SDK default.
1419
- `remind_later_minutes` resolves independently, so "remind me" no longer means
1420
- "hide for a week".
1421
- - **Who this breaks** — dismissal now suppresses where it previously did nothing.
1422
- An integration that layered its own suppression on top may double-suppress.
1423
- - **Landed in** — `0.8.0`
1424
- - **Fail-closed in** — same. Note the *shape* of this one: it shipped in plan 167
1425
- with a proving test that dismissed **without** a `treatmentId`, so both keys
1426
- fell back to `'default'`, matched, and the feature appeared to work for a
1427
- month.
1428
- - **Proving test** — `web-sdk/customer-side-cooldown-suppression.test.ts`
1429
-
1430
- ### A dismissal is no longer treated as "no match"
1431
-
1432
- - **What changed** — `FixedSurfaceSlot` rendered its `fallback` whenever
1433
- `!visible`, and dismissing sets `visible: false` — so closing a placement put
1434
- the fallback back in the same space, a ghost replacing the thing just
1435
- dismissed. A slot now distinguishes the two: no match still renders `fallback`;
1436
- a dismissal renders nothing. `FixedSurfaceSlot` also gains `onDismissed`, and
1437
- `MessageSurfaceSlot`'s `onDismissed` — a public prop whose handler was built
1438
- and then discarded, so it could never fire — now works.
1439
- - **Who this breaks** — a host working around the ghost (one integration used a
1440
- `MutationObserver`) can remove the workaround. `MessageSurfaceSlot`'s
1441
- `onDismissed` goes from never firing to firing.
1442
- - **Landed in** — `0.8.0`
1443
- - **Fail-closed in** — same.
1444
- - **Proving test** — `web-sdk/placements/FixedSurfaceSlot.dismissal.test.tsx`
1445
-
1446
- ### `convert()` retires the placement instead of only reporting it
1447
-
1448
- - **What changed** — `convert()` emitted a `placement_interaction` event and
1449
- stopped: it never wrote the terminal state, so **a user who converted kept
1450
- being shown the upsell they had just paid for**. `dismiss()` and `snooze()` had
1451
- the identical report-but-never-act shape. All three now route through
1452
- `trackTreatmentInteraction`.
1453
- A second defect sat behind the same symptom: nothing in the decision path ever
1454
- *read* the terminal state. `isRetired` was written by `recordConversion` and
1455
- had no caller, so a 5-minute transient window was the only thing hiding a
1456
- converted placement — the upsell returned five minutes after checkout even
1457
- through the controller path. `getPlacementDecision` now checks retirement and
1458
- answers `retired_by_conversion`.
1459
- - **Who this breaks** — `sdk.dismiss()` now writes suppression state where it
1460
- previously did nothing.
1461
- - **Landed in** — `0.8.0`
1462
- - **Fail-closed in** — same.
1463
- - **Proving test** — `web-sdk/convert-retires-placement.test.ts`
1464
-
1465
- ### New: a slot-inventory probe
1466
-
1467
- - **What changed** — additive. `sdk.getRegisteredSlots()` returns the slots this
1468
- app has mounted, and `sdk.diagnoseSlotInventory()` diffs them against the
1469
- Playbook's placement triggers in both directions. A placement targeting a slot
1470
- no code mounts **can never show** and nothing reported it; a config-side audit
1471
- cannot see call sites.
1472
- - **Landed in** — `0.8.0`
1473
- - **Fail-closed in** — n/a, additive and diagnostic.
1474
- - **Proving test** — `web-sdk/slot-inventory-probe.test.ts`
1475
-
1476
- ---
1477
-
1478
- ## 0.7.0
1479
-
1480
- ### `soft_block` → `block_with_upsell`
1481
-
1482
- - **What changed** — the enforcement mode `soft_block` was renamed to
1483
- `block_with_upsell`, and with it the reason codes the SDKs return:
1484
- `usage_limit_reached_soft_block` → `usage_limit_reached_block_with_upsell`, and
1485
- `credit_balance_exhausted_soft_block` → `credit_balance_exhausted_block_with_upsell`.
1486
- **Any integration branching on the old reason string stops matching.**
1487
- - **Landed in** — `0.7.0`
1488
- - **Fail-closed in** — n/a. Behaviour is unchanged, and `soft_block` is still
1489
- accepted wherever it appears in an already-compiled Playbook, decoding to
1490
- exactly the decision it always made. Existing payloads keep working without
1491
- recompilation; only the **string you match on** moved.
1492
- - **Proving test** — the cross-language parity corpus (`tests/parity/`), which
1493
- asserts the reason codes byte-identically across all three runtimes.
1494
-
1495
- ---
1496
-
1497
- ## 0.6.0
1498
-
1499
- ### The server-side decision methods are gone
1500
-
1501
- - **What changed** — removed `RevTurbineServer.evaluate` / `getPlacement` /
1502
- `checkEntitlement` / `can` / `getTrialStatus` (Node), and the whole
1503
- `revturbine_server` module (Python).
1504
- - **Why the breakage is nominal** — every removed method called a hosted decision
1505
- endpoint that no longer exists, so each had been returning a **network error**
1506
- since that endpoint was deleted, in two languages, unnoticed — because the
1507
- cross-port gate only ever asked whether a method of that *name* existed.
1508
- - **What stays** — `RevTurbineServer` itself, and `createClientSession()`, which
1509
- mints the browser-safe token the client SDK's `clientSession` callback
1510
- consumes. An integration using the class only for that is unaffected.
1511
- - **Replacement** — evaluation is a pure function of (user context, Playbook) and
1512
- runs in the SDK. Use `LocalEvaluationServer` (Node) or `RevTurbineCustomerSdk`
1513
- (Python).
1514
- - **Landed in** — `0.6.0`
1515
- - **Fail-closed in** — effectively before `0.6.0`: the methods were already
1516
- erroring. `0.6.0` made the failure a compile/import error instead of a runtime
1517
- one.
1518
- - **Proving test** — `tests/no-decision-endpoint.test.ts`
1519
-
1520
- ---
1521
-
1522
- ## 0.5.0
1523
-
1524
- ### The access gate denies during server rendering
1525
-
1526
- - **What changed** — the gate is constructed inside a `useEffect`, and effects do
1527
- not run during SSR. So on the server there was no gate at all: `isLoading` read
1528
- false, `result` was null, `denied` computed false — and the gate emitted its
1529
- **children**. The server-rendered HTML for a gated feature was literally the
1530
- paid affordance. It now denies until a real verdict exists.
1531
- - **Why this one mattered** — it is a fail-open in the one place the client
1532
- cannot correct it. A crawler or a reader that never runs JS sees the paid
1533
- content.
1534
- - **Landed in** — `0.5.0`
1535
- - **Fail-closed in** — `0.5.0`. This *is* the fail-closed change.
1536
- - **Proving test** — `web-sdk/placements/AccessGateSurfaceSlot.ssr.test.tsx`
1537
-
1538
- ---
1539
-
1540
- ## 0.4.0
1541
-
1542
- ### An unresolvable plan identity denies in Python and Rust
1543
-
1544
- - **What changed** — the headless ports granted where TypeScript denied when a
1545
- user context carried no resolvable plan identity. The ports now deny, matching
1546
- TypeScript.
1547
- - **Landed in** — `0.4.0`
1548
- - **Fail-closed in** — `0.4.0` for Python and Rust; TypeScript already denied.
1549
- **The window between `0.3.0` and `0.4.0` is the expensive one** — a context
1550
- that failed closed in TS silently granted in the other two runtimes.
1551
- - **Proving test** — the cross-language parity corpus, which is what surfaced the
1552
- divergence: the gate had been green while `deriveLocalEntitlementFromConfiguredRules`
1553
- sat unreached by any fixture.
1554
-
1555
- ---
1556
-
1557
- ## 0.3.0
1558
-
1559
- This release is where the `plan: { id }` → `plan_handle` migration landed. It was
1560
- a **minor** bump carrying breaking changes, authorised because there were no
1561
- active tenants and only in-org code referenced the changed surfaces.
1562
-
1563
- ### `plan: { id }` → `plan: { handle }` / `plan_handle`
1564
-
1565
- - **What changed** — a plan's matching identity is its `unique_handle`.
1566
- `plan.id` is DB-internal and no longer participates in matching anywhere, in
1567
- any of the three runtimes.
1568
- - **Symptom if you do not migrate** — a context whose only plan signal is
1569
- `plan.id` now matches **no** plan-targeted rule and fails closed. It does not
1570
- error; it simply has no plan.
1571
- - **Landed in** — `0.3.0`
1572
- - **Fail-closed in** — `0.3.0`.
1573
- - **Proving test** — `web-sdk/user-context-exactness.test-d.ts` plus the
1574
- plan-identity parity fixtures
1575
- (`tests/parity/fixtures/entitlement_plan_identity_is_handle.json`,
1576
- `entitlement_no_plan_identity_denies.json`).
1577
-
1578
- ### `identify()` no longer accepts an arbitrary traits bag
1579
-
1580
- - **What changed** — unknown top-level context keys are a compile error.
1581
- Free-form values go under `custom`, via `update()`.
1582
- - **Landed in** — `0.3.0`
1583
- - **Fail-closed in** — `0.3.0`, at compile time.
1584
- - **Proving test** — `web-sdk/user-context-exactness.test-d.ts`
1585
-
1586
- ### `local_runtime_default_allow` → `entitlement_not_in_playbook`
1587
-
1588
- - **What changed** — the reason code was renamed with **no deprecated alias**,
1589
- and the headless runtimes' terminal fallback now **denies** where it used to
1590
- grant.
1591
- - **Landed in** — `0.3.0`
1592
- - **Fail-closed in** — `0.3.0`. The rename and the fail-closed flip shipped
1593
- together, deliberately: the old name described a grant, so keeping it as an
1594
- alias would have described the new behaviour incorrectly.
1595
- - **Proving test** — the fail-closed suites in `web-sdk/` and the corresponding
1596
- Python and Rust port suites.
1597
-
1598
- ---
1599
-
1600
- ## Before 0.3.0
1601
-
1602
- Versions `0.1.0` through `0.2.91` predate this changelog. Breaking changes in
1603
- that range are recorded only in commit history and release notes. If you are
1604
- upgrading from `0.2.x`, read the `0.3.0` section first — it is the release that
1605
- changed plan identity, and it is the one most likely to affect you.
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
26
+ > was thin (4 of ~73 methods) until BL-0005 (2026-09-22, no release — TSDoc
27
+ > and snapshot only) widened it to **34** of `RevTurbineCustomerSdk`'s **67**
28
+ > non-private methods, plus 5 provider/hook entry points
29
+ > (`initRevTurbine`, `RevTurbineProvider`, `useCan`, `useEntitlement`,
30
+ > `useGatedAction`) — every method a customer-facing guide, skill, the
31
+ > starter, or a demo app calls directly. The remaining ~33 methods are
32
+ > internal-only paths (called only by the SDK's own placement components,
33
+ > React bindings, or other internal packages) and stay untagged, so a
34
+ > breaking change to one of *those* still will not be caught automatically —
35
+ > it depends on whoever writes the PR. Entries here are reliable; the absence
36
+ > of an entry for a currently-untagged method is not yet proof that nothing
37
+ > changed.
38
+
39
+ > **Note on 0.x.** These packages are pre-1.0, so breaking changes ship in the
40
+ > **minor** position (`0.6.0` → `0.7.0`), not the major. `npm`'s caret on a `0.x`
41
+ > range does **not** span minors, so `^0.7.0` will not silently pull `0.8.0`.
42
+
43
+ Built-in decision reason values have a separate reviewed
44
+ [compatibility baseline](docs/reason-code-contract.md). Live fixture checks
45
+ protect those values independently of `@public` method tags; baseline changes
46
+ also require a changelog entry.
47
+
48
+ ---
49
+
50
+ ## 0.11.7
51
+
52
+ ### A rebuilt gate/slot re-runs its check, so a page inside `<Gate>` no longer renders blank (BL-0251)
53
+
54
+ **What changed.** `RevTurbineProvider` re-initializes and publishes a **new**
55
+ `sdk` instance whenever its `options` prop changes identity, with `isReady`
56
+ staying `true`. Hosts do this routinely: options derived from an async session
57
+ change once, shortly after first paint.
58
+
59
+ `useEntitlement` rebuilt its `EntitlementGate` on that change, but the effect
60
+ that *runs* the check keyed on `[autoCheck, isReady, handle, contextKey]` — none
61
+ of which move when only `sdk` does. The replacement gate was therefore never
62
+ checked, and an unchecked gate reports `result: null` with `isLoading: false`,
63
+ which `<Gate>` reads as *unresolved* and renders as `null`. A page whose whole
64
+ body sits inside a `<Gate>` rendered **nothing**, permanently, until the gate was
65
+ remounted — which is why navigating away and back "fixed" it and a hard refresh
66
+ hit it every time (the session settles after the gate mounts).
67
+
68
+ `usePlacement` had the same defect: `loadDecision` was memoized on
69
+ `[isReady, resolvedUserId]`, so a controller rebuilt for a new `sdk` **or** a
70
+ changed slot config was never loaded and the slot stayed empty for the life of
71
+ the mount.
72
+
73
+ Both auto-run dependency lists are now supersets of the lists that rebuild the
74
+ object they act on (`sdk` added to both; `placementKey` added to
75
+ `loadDecision`). No retry, no polling — the trigger simply covers every reason
76
+ the underlying object was replaced.
77
+
78
+ **Landed in.** `0.11.7`.
79
+
80
+ **Fail-closed in.** n/a — this was a fail-**blank**, not a tolerated old shape.
81
+ Nothing a host passes changes.
82
+
83
+ **Proving test.** `web-sdk/placements/AccessGateSurfaceSlot.sdk-rebuild.test.tsx`
84
+ and `web-sdk/react/usePlacement.sdk-rebuild.test.tsx`.
85
+
86
+ ## 0.11.6
87
+
88
+ ### `sdk.convert()` reloads the UserContext, so a mounted slot re-decides (BL-0004)
89
+
90
+ **What changed.** `sdk.convert(outputId)` used to record the conversion and stop
91
+ there. Conversion writes **no** cooldown and **no** permanent retirement — by
92
+ design (Kent's plan 254 D-9 ruling; the `convert` row of the spec's interaction
93
+ table) — so eligibility is a function of the user's CURRENT plan and targeting.
94
+ That left a real gap: a host that called the public convert path while the
95
+ placement was mounted saw the upgrade banner stay on screen until the slot next
96
+ happened to resolve a decision. The documented workaround was a manual
97
+ `refresh()` or the component's `ctaComplete()`. Plan 236 TASK-13 recorded it as
98
+ an open product call rather than asserting it away.
99
+
100
+ Kent ruled on it, 2026-09-25: *"Converting should trigger a reloading of
101
+ UserContext with the new plan/billing state the user has converted to."*
102
+
103
+ So `convert()` now, after recording the interaction:
104
+
105
+ 1. **Optimistically** moves the held plan when the converted output's CTA names
106
+ one (`cta_path.plan_handle` — e.g. `open_checkout` with `config.purchase`).
107
+ The Playbook supplies the plan's display name when it knows it.
108
+ 2. Notifies user-context subscribers **either way** — a CTA that names no plan
109
+ still re-decides mounted surfaces.
110
+ 3. **Then** refreshes from `GET /api/sdk/client-context`, which overlays the
111
+ RevTurbine-authoritative trial / billing-health / plan fields. Best-effort and
112
+ never throwing, exactly as before; a no-op when no client-session minter is
113
+ configured.
114
+
115
+ Optimistic-first because billing truth arrives by Stripe webhook: a
116
+ `client-context` read that races the webhook still reports the OLD plan, and
117
+ waiting for it would leave the upgrade prompt up at the exact moment the user
118
+ paid. For the same reason, while a conversion is pending a server plan **equal to
119
+ the plan converted away from** is dropped (plan fields only — trial and
120
+ billing-health always apply). Any other value clears the pending state and
121
+ applies, so the suppression cannot outlive the lag it covers.
122
+
123
+ Mounted `PlacementController`s re-decide and mounted `EntitlementGate`s re-check
124
+ through `watchUserContext()`. The React bindings attach it for you
125
+ (`usePlacement`, and so every surface slot built on it; `useEntitlement` /
126
+ `useCan` already did), and `SdkSession.placement()` / `.entitlement()` attach it
127
+ for headless consumers. `dispose()` drops it. `PlacementController.ctaComplete()`
128
+ is unchanged.
129
+
130
+ **What did NOT change.** `convert()`'s signature. Conversion still writes no
131
+ suppression and no retirement: back on the old plan, the placement is eligible
132
+ again. The optimistic plan move is a session-local overlay, not a new source of
133
+ plan truth — a host that re-declares `plan_handle` on mount wins, and that is the
134
+ intended precedence.
135
+
136
+ **Landed in.** `0.11.6` (BL-0004; refs BL-0177, BL-0179 for the re-decide
137
+ mechanism this reuses).
138
+
139
+ **Fail-closed in.** `0.11.6` — same version. Nothing was silently tolerated
140
+ before: the old behaviour was a placement that stayed visible, which is why the
141
+ limitation was documented rather than dated.
142
+
143
+ **Proving test.** `web-sdk/convert-reloads-user-context.test.ts` (headless: the
144
+ mounted controller's decision goes invisible, a failed conversion moves nothing,
145
+ a CTA with no plan still notifies, and the stale-server-plan guard both
146
+ suppresses and releases); `web-sdk/react/convert-reloads-user-context.test.tsx`
147
+ (a mounted `usePlacement` banner disappears in place and a mounted `useCan` gate
148
+ flips denied → granted, with no remount); `e2e/journey.spec.ts` BL-0004 legs (a
149
+ real browser: the converted placement leaves the screen with **no** page
150
+ refresh, and the optimistic overlay does not outrank the host's declaration).
151
+
152
+ **Ports.** No change. The Python and Rust SDKs are synchronous-with-config and
153
+ have no mounted surfaces to notify; they expose no `convert()` and no
154
+ UserContext-reload API, so there is no port-side behaviour to match. The
155
+ decision-level contract they do share — a context carrying the new plan yields
156
+ decisions for that plan — is unchanged, so no parity fixture moves.
157
+
158
+ ---
159
+ ## 0.11.5
160
+
161
+ ### `registerSurfaceSlot()` no longer writes to the control plane (BL-0197)
162
+
163
+ **What changed.** Outside `local_only`, `registerSurfaceSlot()` used to mirror
164
+ every slot to the server: `upsertSurfaceSlot()` PUT a legacy surface-slot body
165
+ (`{name, slug, slot_type, status, targeting_rules, content, priority,
166
+ metadata}`) to `/api/placements/<slot id>`, then POSTed it to `/api/placements`
167
+ on the 404. `/api/placements` is RevTurbine's **authored-config** CRUD; it
168
+ validates against `PlacementSchema`, which requires `handle` and `category`, so
169
+ that body could only ever be rejected. In production every non-`local_only`
170
+ slot registration failed with `surface_slot_create_failed:422` and the React
171
+ runtime turned the rejection into a `slot_error`
172
+ (`dogfood_event_explorer`, 2026-09-24 18:36 UTC, request
173
+ `030ccf51-d74b-4d55-bf4e-2a929a4ab297`).
174
+
175
+ The write was also wrong in principle. On a same-origin integration the
176
+ signed-in user's session cookie authenticates the request, so a body the route
177
+ *did* accept would have created a draft placement in the tenant's Playbook just
178
+ from loading a gated page. Only the 422 prevented that.
179
+
180
+ `registerSurfaceSlot()` is now **client-local**: it records the slot in the
181
+ in-process registry and resolves, with no network write of any kind. A write
182
+ happens only when you configure `endpointOverrides.surfaceSlots` (or
183
+ `custom_endpoints.surfaceSlots`) to point at a slot-inventory service you run —
184
+ the same shape `persistPlacementTypes()` already uses. There is **no fallback
185
+ to `/api/placements`** under any configuration.
186
+
187
+ Nothing is lost by removing it. Surface-slot discovery is **ingestion-driven**:
188
+ the SDK already emits `slot_evaluated` / `slot_filled` / `slot_empty` /
189
+ `slot_suppressed` / `slot_error` and the `placement_*` lifecycle through
190
+ `/api/track`, carrying `surface_slot_id`, `slot_name`, `template_ids`,
191
+ `surface_type`, `category`, `decision_source` and `reason_codes`, and the
192
+ pipeline derives discovered slots from that telemetry. `SurfaceSlotSchema` is a
193
+ runtime-source (DISCOVERED) schema with `first_seen` / `last_seen` — it was
194
+ never meant to be written by a browser.
195
+
196
+ **Landed in.** `0.11.5` (BL-0197).
197
+
198
+ **Fail-closed in.** `0.11.5` — same version. The removed path had no silent
199
+ mode: before this release it threw `surface_slot_create_failed:<status>` on
200
+ every default-transport registration, so there is no window where the old
201
+ behaviour was quietly tolerated. An integration that genuinely relied on the
202
+ write must set `endpointOverrides.surfaceSlots`; one that did not was already
203
+ seeing `slot_error`.
204
+
205
+ **Proving test.** `web-sdk/surface-slot-registration-no-write.test.ts` — the
206
+ default transport performs zero writes (fetch spy, no `/api/placements` call)
207
+ and resolves even when the route replays the production 404/422; the override
208
+ path still PUTs, and falls back to POST on the override base, never on
209
+ `/api/placements`. `e2e/journey.transport.spec.ts` no longer stubs
210
+ `/api/placements` with a 200 (the stub that hid this): it records and aborts,
211
+ and `assertNoPlacementWrites()` fails the test if anything calls it.
212
+
213
+ **Ports.** No change — the Python and Rust SDKs never had a slot-registration
214
+ write.
215
+
216
+ ---
217
+ ## 0.11.4
218
+
219
+ ### The treatment-interaction wire record now carries `rule_handle` (BL-0200)
220
+
221
+ **What changed.** `flushInteractionQueue`'s projection onto
222
+ `POST /api/events/interactions` now includes `rule_handle`, taken from the same
223
+ `ruleHandle` on `RevTurbineTreatmentInteractionInput` that 0.11.3 began stamping
224
+ on the `placement_interaction` clickstream event.
225
+
226
+ 0.11.3 deliberately kept it off this record, and #530 asserted the omission with
227
+ a "never reaches the treatment-interaction wire record" test. The reasoning was
228
+ that the clickstream carries payload fields inside a JSON column — readable the
229
+ moment a producer sends them — while this record is column-shaped
230
+ (`placement_presentations`) and a new key needed a datasource migration.
231
+
232
+ The migration has since landed (revturbine-web ledger 015 added
233
+ `placement_exposure_attribution.rule_handle`), and the field is now declared on
234
+ `TreatmentInteractionInputSchema` (`revturbine-scaffold#394`, published as
235
+ `@revt-eng/*` v0.1.342). Sending it lets the app record the rule an exposure was
236
+ **decided by**, on the base exposure row, instead of waiting for the attribution
237
+ worker to reconstruct one from the clickstream when a conversion lands — which
238
+ is what makes presentation-grain analytics cuts per rule honest rather than
239
+ computed over the converted subset.
240
+
241
+ **Nothing to change in your integration.** `ruleHandle` was already public and
242
+ already populated by every `PlacementController` path; this only widens where the
243
+ value is sent. Spread, not set: the key is **absent** when no decision was in
244
+ scope. The contract accepts an explicit `null` for "a rule was selected and none
245
+ matched", but the SDK cannot distinguish that from "no decision in scope" —
246
+ `PlacementOutput.rule_id` is simply missing in both — so it asserts neither.
247
+
248
+ **No `@public` surface moved**, hence a patch release.
249
+
250
+ ## 0.11.3
251
+
252
+ ### `placement_interaction` now carries `rule_handle` (BL-0182)
253
+
254
+ **What changed.** #527 (BL-0062) stamped `rule_handle` at every verdict emit
255
+ site by reading `decision.output.rule_id`, but `placement_interaction` is not
256
+ a lifecycle event and does not spread `placementLifecycleBase` — its
257
+ placement keys are all optional, because a bare `trackTreatmentInteraction`
258
+ caller may supply none of them — so the click between exposure and outcome
259
+ was the one step of the funnel with no rule key. That is the step
260
+ click-through and CTR are computed over: the funnel was sliceable by rule at
261
+ both ends and not in the middle.
262
+
263
+ Five emit sites now reach the same stamped payload: `trackTreatmentInteraction`
264
+ (the projection itself), `PlacementController.trackInteraction`
265
+ (dismiss / remind_me_later / cta_clicked / cta_completed — and therefore every
266
+ React hook and `useSurfaceSlot` / `FixedSurfaceSlot` click handler built on
267
+ it), `PlacementController.fireImpression`, `trackOutputInteraction`
268
+ (output-addressed `convert()` / `dismiss()` / `snooze()`), and the
269
+ server-action `trackResult` resolver.
270
+
271
+ Spread, not set: `rule_handle` is **absent** when no decision was in scope
272
+ (a bare `trackTreatmentInteraction` call with no placement context), and
273
+ `null` on the wire means "a rule was selected and none matched" — conflating
274
+ the two would poison the slice's coverage numbers. The unknown-output branch
275
+ of `trackOutputInteraction` stamps nothing on purpose: no decision from this
276
+ SDK produced that output, so there is no rule to name.
277
+
278
+ Companion to `revturbine-scaffold#391`, which added `rule_handle` to the
279
+ `placement_interaction` payload contract and `ruleHandle` to
280
+ `RevTurbineTreatmentInteractionInput` (published as `@revt-eng/*` v0.1.339,
281
+ pinned in this release).
282
+
283
+ **No wire-shape change beyond the new optional field.** `rule_handle` is
284
+ optional-and-nullable on the schema side (#391), which keeps every
285
+ already-deployed SDK out of `quarantineVerdict`. `@public` signatures are
286
+ unchanged (`web-sdk/generated/public-api.json` untouched) and
287
+ `flushInteractionQueue` still maps the treatment-interaction record field by
288
+ field, so `ruleHandle` never reaches a customer webhook.
289
+
290
+ **Python and Rust are unaffected.** Neither port emits events.
291
+
292
+ **Landed in** `0.11.3` (BL-0182, #530).
293
+
294
+ **Fail-closed in** not applicable — additive field, nothing old is rejected.
295
+
296
+ **Proving test:** `web-sdk/customer-side-canonical-interaction.test.ts`.
297
+
298
+ ---
299
+
300
+ ### A gate no longer paywalls forever when it loses the cold-start config race (BL-0179)
301
+
302
+ **What changed.** BL-0177 (below) split `config_unavailable` into a transient and
303
+ a terminal state for **placements** and left `checkEntitlement` on the old
304
+ behaviour. So the same cold-start race denied entitlements instead: while the
305
+ Playbook load was in flight, `checkEntitlement` — and therefore `can()`,
306
+ `gate()`, `useCan`, `useEntitlement` and `useGatedAction` — returned the
307
+ fail-closed deny with reason `config_unavailable`, which is indistinguishable
308
+ from a rule denial. A gate mounted before config resolution rendered its paywall
309
+ and never re-evaluated, hiding a feature the user had paid for.
310
+
311
+ The entitlement path now takes the same two-state split:
312
+
313
+ | state | when | behaviour |
314
+ |---|---|---|
315
+ | **transient** | a Playbook load is in flight (`getPlaybookLoadState() === 'loading'`) | `checkEntitlement` waits for it (bounded, 4s) and re-evaluates. If the load outruns that bound, `EntitlementGate` **parks** the deny instead of publishing it — `result` stays `null`, `denied` stays `false`, `isLoading` stays `true`, and **no** `gate_evaluated` is emitted — then re-checks when the load settles, at most twice per cycle. |
316
+ | **terminal** | no config provider is configured, or the load settled without a config | unchanged: fail-CLOSED deny, reason `config_unavailable` in Server mode / `entitlement_not_in_playbook` in local mode, with its `resolution_failure` diagnostic and exactly one `gate_evaluated` carrying the verdict (and its `rule_handle`). |
317
+
318
+ The emission rule is the one worth stating plainly: **exactly one
319
+ `gate_evaluated` per settled verdict, and none for the transient state** — a
320
+ lost race is not an evaluation and must not enter the gate funnel as a denial.
321
+
322
+ **No API, wire or reason-code change.** `@public` signatures are unchanged
323
+ (`web-sdk/generated/public-api.json` is untouched), both reason codes keep their
324
+ meanings, and `tests/reason-contract.json` is unchanged. A `limited`-but-allowed
325
+ result is untouched; the fail-closed contract for terminal unavailability is
326
+ untouched.
327
+
328
+ **Python and Rust are unaffected by design**, for the same reason as BL-0177:
329
+ both take the Playbook as a required constructor argument, do no I/O, and have no
330
+ not-yet-loaded window.
331
+
332
+ **Landed in.** `0.11.3` (BL-0179, #529).
333
+
334
+ **Fail-closed in.** Not applicable — terminal behaviour is unchanged; only the
335
+ transient race is now absorbed.
336
+
337
+ **Proving test.** `web-sdk/react/useCan.config-race.test.tsx` (a gate mounted
338
+ mid-race ends on the rule's verdict, allow and deny) and
339
+ `web-sdk/entitlement-config-race.test.ts` (both states, the emission rule, and
340
+ the bounded post-bound retry for `PlacementController` and `EntitlementGate`,
341
+ reached by injecting a `0` wait bound rather than with fake timers).
342
+
343
+ ---
344
+
345
+ ### A slot no longer paints its fallback forever when it loses the cold-start config race (BL-0177)
346
+
347
+ **What changed.** `getPlacementDecision` returns `config_unavailable` when the
348
+ Playbook has not arrived, uncached, specifically so a retry can succeed — but
349
+ nothing retried. `useSurfaceSlot` / `FixedSurfaceSlot` decide once per mount, so
350
+ on a cold `revturbine_server` load, whenever the first decision beat the two-hop
351
+ bootstrap → `/api/sdk/config` chain, the slot rendered its `fallback`
352
+ permanently and every integration had to hand-roll a retry.
353
+
354
+ `config_unavailable` now has two distinct states and the SDK handles each:
355
+
356
+ | state | when | behaviour |
357
+ |---|---|---|
358
+ | **transient** | a Playbook load is in flight | `getPlacementDecision` waits for it (bounded, 4s) and re-resolves. If the load outruns that bound, `PlacementController` re-decides when it settles — at most twice per load cycle. The slot stays in `isLoading` meanwhile and emits **no** `placement_resolved` / `slot_empty`: a lost race is not a resolution and must not enter the funnel as one. |
359
+ | **terminal** | no config provider is configured, or the load settled without a config | unchanged from before: the decision is `config_unavailable`, `visible: false`, uncached, with the resolution-failure diagnostic and the lifecycle + slot events. Bounded retries mean it settles rather than spinning. |
360
+
361
+ **No API, wire or reason-code change.** `config_unavailable` is still the reason
362
+ code, the decision shape is untouched, and `tests/reason-contract.json` is
363
+ unchanged. Customer code that already hand-rolls a retry keeps working — it is
364
+ now redundant, not wrong.
365
+
366
+ **Python and Rust are unaffected by design.** Both ports take the Playbook as a
367
+ required constructor argument and raise on a missing or malformed one
368
+ (`server-python/src/revturbine/sdk.py` `__init__`, `server-rust/src/sdk.rs`
369
+ `new`), perform no I/O, and never emit `config_unavailable` — there is no
370
+ not-yet-loaded window to retry. This is a browser/hosted-mode state only.
371
+
372
+ **Landed in.** `0.11.3` (BL-0177, #528).
373
+
374
+ **Fail-closed in.** Not applicable — the terminal behaviour is unchanged; only
375
+ the transient race is now absorbed.
376
+
377
+ **Proving test.** `web-sdk/placements/FixedSurfaceSlot.config-race.test.tsx`
378
+ (both states), plus `e2e/journey.transport.spec.ts`, whose two hand-rolled retry
379
+ loops were deleted — a retry loop reappearing there is the regression signal.
380
+
381
+ ---
382
+
383
+ ## 0.11.1
384
+
385
+ ### The generated schema *types* gained their `Playbook*` names (BL-0165)
386
+
387
+ **What changed.** `0.11.0` renamed every option, keyword and method that carries a
388
+ Playbook. It did not rename the **generated type identifiers**, which come from
389
+ scaffold and are vendored here — so a `0.11.0` integration still had to import
390
+ `ExportedConfigSegmentsItem` to name a segment. Those types now have `Playbook*`
391
+ spellings too.
392
+
393
+ | deprecated | canonical |
394
+ |---|---|
395
+ | `ExportedConfigSegmentsItem` | `PlaybookSegmentsItem` |
396
+ | `ExportedConfigSegmentsItemPredicatesItem` | `PlaybookSegmentsItemPredicatesItem` |
397
+ | `ExportedConfigPlacementItem` | `PlaybookPlacementItem` |
398
+ | `ExportedConfigUiPathActionType` | `PlaybookUiPathActionType` |
399
+ | `ExportedConfig` | `Playbook` (already shipped in `0.11.0`) |
400
+
401
+ **This renames nothing on the wire.** The names are generated from exported keys
402
+ of scaffold's Zod barrel: both spellings resolve to the **same schema object**, so
403
+ no payload field, default or validation rule differs between them, and the OpenAPI
404
+ component names are unchanged (they come from `.meta({ id })` on the underlying
405
+ schemas, not from the key). A Playbook written by an older SDK parses byte-for-byte
406
+ identically. Scaffold's payload contract — `bundle_schema_version` /
407
+ `bundle_min_readable_schema_version` — is untouched, so there is no minimum-reader
408
+ floor to raise.
409
+
410
+ **The aliases are duplicate definitions, not type aliases.** Scaffold's generators
411
+ emit one full TS type, Pydantic model and serde struct per barrel key rather than an
412
+ alias, so in Python (`revturbine.types`) and Rust (`revturbine::types`) the old
413
+ names remain as their own complete models/structs, generated from the same schema.
414
+ No hand-written shim exists in either port, and none is wanted: it would collide
415
+ with the generated definition. In TypeScript the `core` barrel re-exports the old
416
+ pair with `@deprecated` TSDoc.
417
+
418
+ Requires `@revt-eng/schema` / `@revt-eng/core` ≥ 0.1.335, which added the
419
+ `Playbook*` keys upstream (scaffold #387).
420
+
421
+ Ruling: Kent, 2026-09-23 (BL-0156 — `ExportedConfig` naming deprecated, Playbook
422
+ canonical).
423
+
424
+ **Landed in** `0.11.1` (additive — every old name still resolves).
425
+ **Fail-closed in** `0.12.0`, when BL-0167 removes the `ExportedConfig*` keys from
426
+ scaffold's barrel and the generated definitions disappear from the vendored types
427
+ along with them. That is the same window as the `0.11.0` option/method aliases, so
428
+ an integration has one removal to absorb, not two.
429
+
430
+ **Proving test:** scaffold's `src/core/zod/playbook-generated-names.test.ts` (each
431
+ `Playbook*` key is the same object as its `RevTurbineConfig*` and `ExportedConfig*`
432
+ counterparts; a legacy `export-config.json` payload parses to an equal result
433
+ through both spellings of the config schema), plus this repo's
434
+ `scripts/check-vendored-types.mjs` (the vendored py/rs copies agree with the pin).
435
+
436
+ **Public-API snapshot: unchanged.** `pnpm check:public-api` still reports the same
437
+ **62** `@public` exports and `web-sdk/generated/public-api.json` is byte-identical,
438
+ because the `core` barrel's type re-exports carry no `@public` tag. Recorded here
439
+ rather than left implicit: a rename touching the SDK's type vocabulary that moves
440
+ *no* snapshot entry is worth stating, so a reader does not go looking for the diff.
441
+ No existing export's signature changed and none was removed.
442
+
443
+ ## 0.11.0
444
+
445
+ ### `Playbook` is the canonical name for every option that carries one (BL-0156)
446
+
447
+ **What changed.** `ExportedConfig` is dead vocabulary. Plan 118 renamed the
448
+ domain object to **Playbook** and plan 104 renamed the schema type to
449
+ `RevTurbineConfig`, but the *option*, *keyword* and *method* names that carry a
450
+ Playbook around the SDK still spelled it `exportedConfig` / `exported_config`.
451
+ Every one of them now has a `Playbook`-named counterpart; every old name stays
452
+ as a deprecated alias that still works.
453
+
454
+ TypeScript:
455
+
456
+ | deprecated | canonical |
457
+ |---|---|
458
+ | `localRuntime.exportedConfig` | `localRuntime.playbook` (already shipped; see below) |
459
+ | `localRuntime.resolvers.resolveExportedConfig` | `localRuntime.resolvers.resolvePlaybook` |
460
+ | `configProvider.getExportedConfig()` | `configProvider.getPlaybook()` |
461
+ | `sdk.getExportedConfig()` | `sdk.getPlaybook()` |
462
+ | `getPolicy().exportedConfigVersion` | `getPolicy().playbookVersion` |
463
+ | `BrowserRuntimeOptions.exportedConfig` | `BrowserRuntimeOptions.playbook` |
464
+ | `LocalEvaluationServerOptions.exportedConfig` | `LocalEvaluationServerOptions.playbook` |
465
+ | `ExportedConfigProvider` (type) | `RevTurbineConfigProvider`, also exported as `PlaybookProvider` |
466
+ | `ExportedConfig` (type) | `Playbook` |
467
+
468
+ Python: `RevTurbineCustomerSdk(exported_config=…)`, `LocalRuntime(exported_config=…)`,
469
+ `LocalRuntime.get_exported_config()`, `create_static_placement_resolver(exported_config=…)`,
470
+ `derive_local_entitlement_from_configured_rules(exported_config=…)`,
471
+ `configured_plan_name_from_exported_config`, `parse_exported_config_or_throw` and
472
+ `static.ExportedConfig` all gained `playbook`-spelled counterparts.
473
+
474
+ Rust: `configured_plan_name_from_exported_config` is now
475
+ `configured_plan_name_from_playbook`, with the old name kept as the crate's
476
+ first `#[deprecated]` item. Rust has no keyword arguments, so nothing else in
477
+ that port was a caller-visible name.
478
+
479
+ `RevTurbineConfigProvider`'s two accessors are both OPTIONAL on the interface so
480
+ that an existing `implements RevTurbineConfigProvider` clause keeps compiling;
481
+ the `configProvider` option intersects it with a union that still requires
482
+ exactly one of them, so a provider implementing neither is a compile error where
483
+ it is passed.
484
+
485
+ Reading a deprecated name logs **one** development-build warning per runtime
486
+ (one `DeprecationWarning` per process on Python) naming the canonical
487
+ replacement — one flag for every alias, so an integration still spelling several
488
+ of them gets a single line, not a wall. Production builds are silent. Requires
489
+ `@revt-eng/core` ≥ 0.1.331, which made the same rename upstream (scaffold #380).
490
+
491
+ Ruling: Kent, 2026-09-23 (BL-0156).
492
+
493
+ **Landed in** `0.11.0`. **Fail-closed in** `0.12.0` for the `ExportedConfig`
494
+ aliases — until then every old name works and warns once.
495
+
496
+ **Proving test:** `web-sdk/playbook-option.test.ts` (canonical never warns, the
497
+ alias still resolves, one warning across three read sites, both spellings reach
498
+ the same Playbook at every public read site),
499
+ `server-python/tests/test_playbook_option.py` (the same contract plus
500
+ `get_exported_config()`), and `server-rust/src/user_context.rs`
501
+ `playbook_rename_tests` (the alias returns exactly what the canonical returns).
502
+
503
+ ### `localRuntime.playbook` is the canonical local-mode config key
504
+
505
+ **What changed.** The record this file owed and never carried: `localRuntime.playbook`
506
+ shipped earlier as the canonical spelling of `localRuntime.exportedConfig`, with
507
+ `resolveLocalPlaybook()` as the single resolver deciding precedence, but no
508
+ changelog entry was ever written for it — so an integration could not date the
509
+ change. It is recorded here rather than back-dated, because the version it
510
+ landed in is not the version this entry appears in and pretending otherwise is
511
+ exactly the confusion the "Landed in" / "Fail-closed in" split exists to
512
+ prevent. As of `0.11.0` the alias also warns once (it did not before).
513
+
514
+ **Landed in** a release before `0.11.0` (additive; the alias never stopped
515
+ working). **Fail-closed in** `0.12.0`, with the rest of the `ExportedConfig`
516
+ aliases.
517
+
518
+ **Proving test:** `web-sdk/playbook-option.test.ts` — `resolveLocalPlaybook`
519
+ accepts either key, prefers `playbook`, and warns only for the alias.
520
+
521
+ ### `publicKey` is the only browser credential; `ingestPublicKey` is gone (BL-0113)
522
+
523
+ **What changed.** `0.10.0` made `publicKey` the browser credential and kept
524
+ `apiKey` and `ingestPublicKey` working as aliases "for one minor". This is that
525
+ minor:
526
+
527
+ - **`ingestPublicKey` is removed from `RevTurbineInitOptions`.** Passing it is
528
+ now a **type error**, including when the options are built in a variable
529
+ (`ExactInitOptions` rejects it there too).
530
+ - **`apiKey` is no longer read as a browser credential.** It still exists, and
531
+ still means exactly one thing: the secret **server key**, for
532
+ `@revturbine/sdk/server` and for the headless SDK on a backend. A browser init
533
+ that supplies only `apiKey` now resolves **no** browser credential and fails
534
+ at init, instead of silently sending a server key as the bearer.
535
+
536
+ `resolveBrowserPublicKey()` reads `publicKey` and nothing else, and
537
+ `resetBrowserKeyAliasWarning()` is gone with the warning it reset. Keyless
538
+ local-only init is unchanged, and the plan 95 anonymous `sdk_init` beacon now
539
+ keys off `publicKey` alone.
540
+
541
+ Ruling: Kent, 2026-09-23 (BL-0113), closing the window plan 257 opened.
542
+
543
+ **Landed in** `0.11.0`. **Fail-closed in** `0.11.0` — the same release. This IS
544
+ the fail-closed half of the `0.10.0` entry below; there is no further tolerance
545
+ window.
546
+
547
+ **Proving test:** `web-sdk/public-key-option.test.ts` (`publicKey` is the bearer
548
+ on ingest and every control-plane fetch; `apiKey` resolves nothing; nothing
549
+ warns), `web-sdk/customer-side-ingest.test.ts` (the bearer no longer carries the
550
+ server key), and `web-sdk/init-options-exactness.test-d.ts` (`ingestPublicKey`
551
+ is a type error inline AND in a variable — if the key is ever re-added, `tsc`
552
+ fails on the unused `@ts-expect-error`).
553
+
554
+ ---
555
+
556
+ ## 0.10.10
557
+
558
+ ### Trial tokens now reach placement content, not only `getPersonalizationTokens()` (BL-0169)
559
+
560
+ **What changed.** A placement whose copy authors `{{trial_days_remaining}}` or
561
+ `{{trial_days_total}}` now renders the live values. Content interpolation sources
562
+ its token map from the **output content itself**, so a provider-derived token
563
+ only reaches the copy if the resolver writes it onto that content first. The
564
+ usage lane always did (`usage_current`, `usage_limit`, `usage_percent`,
565
+ `usage_remaining`, `reset_date`); the trial lane never did, so a `trial_ending`
566
+ body reading `"{{trial_days_remaining}} days left"` shipped the literal
567
+ `{{trial_days_remaining}}` to the end user — on every port. The two names were
568
+ derived for `getPersonalizationTokens()` all along, which is why the gap survived:
569
+ a host that read the token map saw the right numbers and a host that authored the
570
+ token into copy did not.
571
+
572
+ | | before | now |
573
+ |---|---|---|
574
+ | `getPersonalizationTokens().trial_days_remaining` | live value | live value (unchanged) |
575
+ | content `{{trial_days_remaining}}` / `{{trial_days_total}}` | literal `{{…}}` | live value |
576
+ | `outputContent.trial_days_remaining` / `_total` | absent | written when the plan provider carries a finite number |
577
+
578
+ Identical on all three ports (`ts:local-resolver.ts`,
579
+ `py:core/placements/local_resolver.py`, `rs:placements/static_resolver.rs`): the
580
+ same two token names, a `Number.isFinite` guard, provider state winning over an
581
+ authored content value of the same key, and the value passed through **without
582
+ widening** — `day_number` 7 + `days_remaining` 3 is `trial_days_total` `10`, never
583
+ `10.0` (BL-0155).
584
+
585
+ **Who this reaches.** Any host whose placement copy authors either token. Copy
586
+ that hard-coded one of those keys as a decorative literal now has it overwritten
587
+ by live state — the same precedence the usage lane and
588
+ `derivePlacementPersonalizationTokens` have always had. `{{trial_plan_name}}` and
589
+ `{{trial_features_used}}` remain **not yet wired** by the placement-studio-ui
590
+ spec's own note and are untouched.
591
+
592
+ **Landed in.** `0.10.10`.
593
+
594
+ **Fail-closed in.** `0.10.10` — absence still leaves the raw token standing
595
+ rather than rendering `0`, so a host with no trial provider state sees exactly
596
+ what it saw before.
597
+
598
+ **Proving test.** Parity scenario `trial_tokens_in_content` under
599
+ `"normalize": "preserve-representation"` (byte-locked ts ≡ py ≡ rs), plus
600
+ `py:tests/placements/test_local_resolver.py` and `rs:tests/static_resolver.rs`
601
+ `trial_tokens_are_injected_from_plan_provider_state` and its two neighbours,
602
+ mirroring the scaffold-side `local-resolver.test.ts` cases. Needs
603
+ `@revt-eng/core` 0.1.331 or later (scaffold #382) for the TS half; this release
604
+ pins 0.1.332.
605
+
606
+ ---
607
+
608
+ ## 0.10.9
609
+
610
+ ### The trial-status overlay no longer changes a number's type (BL-0155)
611
+
612
+ **What changed.** The `UserTrialStatus` → `trial_*` PlanProviderState overlay now
613
+ passes each numeric field through on every port, instead of two ports widening
614
+ it to a float. The canonical behaviour is the TS SDK's
615
+ `synthesizeProviderContext` (`web-sdk/customer-side.ts`, the `planTrialFields`
616
+ block), which spreads every `UserTrialStatus` member verbatim: an integer
617
+ `progress_percent` stays an integer on the provider state, and
618
+ `trial_days_total` is `day_number + days_remaining` in the inputs' own kind, so
619
+ `7 + 3` is `14`. The two server ports diverged from it:
620
+
621
+ | field | Python before | Rust before | all three now |
622
+ |---|---|---|---|
623
+ | `progress_percent` → `trial_progress_percent` | `float(v)` | verbatim | verbatim |
624
+ | `days_remaining` → `trial_days_remaining` | `float(v)` | verbatim | verbatim |
625
+ | `usage_consumed` → `trial_usage_consumed` | `float(v)` | verbatim | verbatim |
626
+ | `usage_limit` → `trial_usage_limit` | `float(v)` | verbatim | verbatim |
627
+ | `in_trial` → `trial_active` | `bool(v)` | verbatim | verbatim |
628
+ | derived `trial_days_total` | `float(a) + float(b)` | `f64` sum | `int + int` → `int`; any fractional half → float |
629
+
630
+ **Who this reaches.** Only a host that reads the provider state it built —
631
+ `revturbine::overlay_trial_status_on_plan_provider` on Rust, or the merged
632
+ `PlanProviderState` on Python — and cares whether it holds `70` or `70.0`
633
+ (serializing it, comparing it to an integer, or logging it). No decision
634
+ changes: every reader in the trial-gating layer widens on read, which is exactly
635
+ why the divergence survived. A non-numeric `day_number`/`days_remaining` half
636
+ now derives no total on Python instead of raising, matching Rust's existing
637
+ numeric guard.
638
+
639
+ **Landed in.** `0.10.9`.
640
+
641
+ **Fail-closed in.** `0.10.9` — there was no tolerated-old-shape window; the
642
+ representation simply changes.
643
+
644
+ **Proving test.**
645
+ `server-python/tests/test_trial_overlay_upsert.py::test_overlay_preserves_integer_numeric_representation`
646
+ and `server-rust/src/sdk.rs::overlay_preserves_integer_numeric_representation`
647
+ — both assert the *type*, not just the value, because `100 == 100.0` in Python.
648
+ Scenario `trial_overlay_integer_fields` locks that the three ports still decide
649
+ identically from an all-integer trial status.
650
+
651
+ > **Correction, 2026-09-23 (BL-0158, harness-only — no release).** This entry
652
+ > originally said the parity corpus *cannot* see this class of divergence,
653
+ > because `tests/parity/normalize.*` rule 4 collapses integral floats by design
654
+ > and nothing in the corpus returned the overlaid provider state. Both halves
655
+ > are now closed and the sentence no longer holds:
656
+ > `trial_overlay_integer_fields` drives `resolveProviders` (already shipped on
657
+ > all three runtimes) under a new per-scenario
658
+ > `"normalize": "preserve-representation"` flag that turns the collapse off, so
659
+ > `14` and `14.0` are different bytes and the cross-language byte-diff fails on
660
+ > a widened field. No public API changed; the harness gained the ability to see
661
+ > provider state.
662
+
663
+ ---
664
+
665
+ ## 0.10.8
666
+
667
+ ### `{{recommended_plan_name}}` and `{{plan_name}}` no longer reach end users as raw tokens (BL-0121)
668
+
669
+ **What changed.** The placement-decision lane now substitutes the five tokens
670
+ the SDK resolves from the Playbook — `plan_name`, `plan_price`,
671
+ `upgrade_plan_price`, `recommended_plan_handle`, `recommended_plan_name` —
672
+ into `RevTurbinePlacementDecision.content` (and the mirrored
673
+ `decision.output.content`). It previously substituted `plan_price` and
674
+ `upgrade_plan_price` only.
675
+
676
+ `recommended_plan_handle` / `recommended_plan_name` were already derived, by
677
+ the parity-locked `resolveRecommendedPlanTokens` dispatch, and already readable
678
+ via `getPersonalizationTokens()`. Nothing wrote them into rendered content, so
679
+ a placement authored with `"Upgrade to {{recommended_plan_name}}"` shipped the
680
+ literal braces to the end user. Observed on the CybeDefend demo, 2026-09-22.
681
+
682
+ Two properties of the substitution are deliberate:
683
+
684
+ | input | result |
685
+ |---|---|
686
+ | a token the SDK owns but cannot resolve (top-of-ladder user has no next plan) | empty string — the spec's documented empty-token convention |
687
+ | a token the SDK does not own (`{{usage_percent}}`, an app-defined token) | left verbatim, so the React render lane still resolves it |
688
+
689
+ `plan_name` resolves through the plan's `unique_handle`, the matching identity
690
+ — the lookup used to be handed the plan *object*, which by design resolves
691
+ nothing, so `{{plan_name}}` rendered raw for every user identified by
692
+ `plan_handle`.
693
+
694
+ **Landed in.** `0.10.8`.
695
+
696
+ **Not fixed here.** `{{upgrade_plan_price}}` still renders the ANNUAL amount
697
+ for a plan that has both an annual and a monthly variation. Variation
698
+ preference keys off the user's billing cadence, but no supported input carries
699
+ it (`billing_period` is absent from `UserContextSchema` and from the recognized
700
+ `identify()` / `update()` keys), so selection falls through to the
701
+ alphabetically-first variation and `<plan>_annual` always wins. Closing it
702
+ needs a schema field for the cadence and a contract for which period the token
703
+ reflects when the cadence is unknown; both are open on BL-0121.
704
+
705
+ **Proving test.** `web-sdk/customer-side-upgrade-tokens.test.ts`.
706
+
707
+ ---
708
+
709
+ ## 0.10.7
710
+
711
+ ### The Python and Rust ports stop filtering payloads on `status` (BL-0151)
712
+
713
+ **What changed.** Payload `status` is no longer a runtime predicate in any port.
714
+ Python and Rust required `status == "active"` on a placement payload before it
715
+ could become a candidate, and read a `status` off each content-linked studio
716
+ payload before overlaying its copy. Both now ignore it, matching the TypeScript
717
+ port, which never gated on it.
718
+
719
+ | | Before | After |
720
+ |---|---|---|
721
+ | Placement payload candidacy (Python, Rust) | required `status == "active"` | every payload, status ignored |
722
+ | Content-linked copy overlay (Python, Rust) | required the studio payload's `status` to read `active` | always overlaid |
723
+ | TypeScript | already ignored `status` | unchanged |
724
+
725
+ **Why it mattered.** `status` is not a field of the exported Playbook.
726
+ `RevTurbineConfigStudioPayload` and `RevTurbineConfigPlacementPayloadItem` carry
727
+ no `status` key, unknown keys are stripped on parse, and the published JSON
728
+ Schema is `additionalProperties: false` — because runtime status is *derived*
729
+ control-plane side and the stored column was dropped. So `status == "active"` was
730
+ false for **every** payload of a real Playbook: the Python and Rust resolvers
731
+ indexed no candidates at all and returned `placement_not_found`, and their
732
+ content-lookup adapters dropped every content-linked payload, keeping the inline
733
+ copy where TypeScript overlaid the linked block. A config whose first payload
734
+ carried a stray authored `status` also decided differently across ports.
735
+
736
+ The cross-language parity gate could not see any of this: the only fixtures
737
+ reaching these paths hand-write `status` on each payload — a key the schema would
738
+ strip — so the ports were compared on a shape no Playbook has.
739
+
740
+ **Landed in.** 0.10.7 (`server-python`, `server-rust`). No `@revt-eng/core`
741
+ change rides with it: TypeScript was already correct, so there is no scaffold
742
+ release and no pin bump.
743
+
744
+ **Fail-closed in.** n/a — this release removes a fail-*closed* filter. Python and
745
+ Rust consumers on a real Playbook go from no decision to the decision
746
+ TypeScript has always returned.
747
+
748
+ **Proving test.** `tests/parity/fixtures/payload_status_filter.json` — two lanes:
749
+ a stray authored `status` that disagrees with drag order (payload 1 `draft`,
750
+ payload 2 `active`; payload 1 must still win), and the real wire shape with no
751
+ `status` anywhere plus a content-linked overlay. Mutation-checked by reinstating
752
+ the Python filter, which diverges on the first call. Plus
753
+ `server-python/tests/placements/test_local_resolver.py`,
754
+ `test_json_content_provider.py` and `server-rust/tests/static_resolver.rs`, each
755
+ covering both the stray-value and the no-key case.
756
+
757
+ **Who is affected.** Every `revturbine` (PyPI) and `revturbine` (crates.io)
758
+ consumer resolving placements from a schema-valid exported Playbook — that is,
759
+ one produced by the control plane rather than hand-written with a `status` field.
760
+ Those integrations were receiving no placement decisions at all.
761
+
762
+ ---
763
+
764
+ ## 0.10.6
765
+
766
+ ### The trial-status PlanProvider overlay ships in the Rust crate (BL-0153)
767
+
768
+ **What changed.** `revturbine::overlay_trial_status_on_plan_provider` is a new
769
+ public function on the Rust crate: it maps a runtime `UserTrialStatus` onto the
770
+ `trial_*` fields of a resolved PlanProviderState, the fields the placement
771
+ resolver's `trial_progress` / `trial_ending` / `trial_ended` /
772
+ `trial_converted` gates and milestone supersession read. Python has always done
773
+ this inside its shipped package
774
+ (`revturbine.sdk._overlay_trial_status_on_plan_provider`); on Rust the mapping
775
+ existed only as a private helper the facade used, so a host that assembles its
776
+ own provider context — including this repo's own parity runner — had to
777
+ re-implement it. Additive: no existing signature changed.
778
+
779
+ Porting Python's mapping faithfully corrected two Rust-only divergences in the
780
+ overlay the facade applies:
781
+
782
+ | `UserTrialStatus` field | Rust before | Rust now (= TS/Python) |
783
+ |---|---|---|
784
+ | `day_number` + `days_remaining` | not derived | `trial_days_total = day_number + days_remaining`, only when both are present |
785
+ | `day_number` | written to `trial_day_number` | not written — no provider-state field reads it |
786
+ | `usage_entitlement_handle` | dropped | `trial_usage_entitlement_handle` |
787
+
788
+ `trial_days_total` is the input to the time-mode progress fallback in
789
+ `placements::trial_gating`, so before this the fallback could never fire on the
790
+ Rust port.
791
+
792
+ **Landed in.** `0.10.6`.
793
+
794
+ **Fail-closed in.** `0.10.6` — the parity facade-presence gate
795
+ (`tests/parity/facade-surface.json`) no longer allow-lists a runner-local
796
+ overlay, so a future re-implementation harness-side fails the gate.
797
+
798
+ **Proving test.** `server-rust/src/sdk.rs` `overlay_maps_every_canonical_trial_field`,
799
+ `trial_days_total_requires_both_halves`, `overlay_does_not_clobber_base_state_with_null`
800
+ (mirroring `server-python/tests/test_trial_overlay_upsert.py`), plus the
801
+ `trial_ending_days_before_end`, `trial_ended_post_expiry` and
802
+ `trial_progress_milestone_supersession` parity fixtures.
803
+
804
+ ---
805
+
806
+ ## 0.10.5
807
+
808
+ ### Trial-only local integrations get provider context (BL-0120)
809
+
810
+ **What changed.** `synthesizeProviderContext()` — the fallback that builds
811
+ `DomainProvider` state from `userContext` when no explicit domain provider is
812
+ registered — dropped an integration's trial data whenever no commercial
813
+ `plan` / `plan_handle` was also present. An app supplying **only**
814
+ `initialData.trialStatus` (or `setTrialInstances()` / `setTrialStatus()` with
815
+ no plan) got no provider context at all: `providers.plan` came back
816
+ `undefined`, so `{{trial_days_remaining}}` (and every other `trial*` token
817
+ `derivePlacementPersonalizationTokens` derives from `providers.plan`) stayed
818
+ raw, unresolved, in placement copy. Reported against the CybeDefend demo
819
+ (SDK 0.7.13): a `trial_ending` placement with `in_trial: true,
820
+ days_remaining: 3` left `{{trial_days_remaining}}` literal in the rendered
821
+ body.
822
+
823
+ Two omissions, same root cause — trial was never checked alongside
824
+ `plan` / `plan_handle` / `usage` / `tiers` / `experiments`:
825
+
826
+ - the early-return guard (`if (!plan && !planHandle && !usage && !hasTiers
827
+ && !hasExperiments) return undefined;`) treated a trial-only integration as
828
+ having no signal whatsoever;
829
+ - even past that guard, the returned `plan` key itself was gated on
830
+ `plan || planHandle` — so the trial fields (`trialActive`,
831
+ `trialDaysRemaining`, etc.) it computed were still discarded for a
832
+ plan-less trial.
833
+
834
+ Both gates now also check `hasTrial` (an explicit trial signal — the default
835
+ untouched `{ in_trial: false }` sentinel does not count, so the omitted-signal
836
+ short-circuit is otherwise unchanged for integrations that supply nothing at
837
+ all). `usage`-only, `tiers`-only, and `experiments`-only integrations already
838
+ threaded through correctly before this change; trial was the only signal
839
+ missing from both checks.
840
+
841
+ No public API change — `synthesizeProviderContext` is private, and the fix is
842
+ additive: a plan-less trial integration now gets a `providers.plan` it never
843
+ got before, with an empty `currentPlanHandle: ''` (no plan name/handle was
844
+ ever supplied to derive one from).
845
+
846
+ **Landed in** `0.10.5`. **Fail-closed in** n/a — a previously-dropped signal
847
+ is now included; nothing that resolved before stops resolving.
848
+
849
+ **Proving test:** `web-sdk/customer-side-trial-token-signal.test.ts` — a
850
+ trial-only local integration now gets a non-empty provider context with
851
+ `trialActive`/`trialDaysRemaining` set, and feeding those through
852
+ `derivePlacementPersonalizationTokens` + `resolveContent` renders
853
+ `{{trial_days_remaining}}` as `"3"` instead of leaving it literal. Includes
854
+ the positive control (trial + plan together — unchanged), the negative
855
+ control (no trial data at all — the token stays absent, never coerced to
856
+ `"0"`), and a sibling check confirming usage-only integrations were already
857
+ unaffected by the guard.
858
+
859
+ ---
860
+
861
+ ## 0.10.4
862
+
863
+ ### Every payload is a selection candidate, not just the first (BL-0122)
864
+
865
+ **What changed.** A placement's payloads are now *all* candidates on both
866
+ selection paths, in all three ports. Previously each port considered exactly one
867
+ payload per placement — TypeScript took `payloads[0]`, Python and Rust the first
868
+ payload with `status == "active"` — so payloads 2+ were never candidates and
869
+ their `target.segment_chips` could not be evaluated at all.
870
+
871
+ | | Before | After |
872
+ |---|---|---|
873
+ | Candidacy | one payload per placement | every payload (Python/Rust: every `active` payload) |
874
+ | `target.segment_chips` on payloads 2+ | never evaluated | evaluated per payload |
875
+ | Drag precedence | a pre-filter — it chose the only candidate | a tiebreaker among the payloads the user **matches** |
876
+ | Direct lookup by name/id | mapped a name to one payload | maps a name to every payload, gated individually, first eligible wins |
877
+
878
+ The contract this restores is
879
+ [`placement-prioritization.md`](https://github.com/revt-eng/revturbine-devkit/blob/main/docs/specs/scaffold/placement-prioritization.md)
880
+ §1 stage 3 — "Targeting — the user's plan and segment match **a payload**" — with
881
+ §4 and Appendix D scoping drag precedence to payloads that are equally eligible.
882
+
883
+ **Why it mattered.** A placement holding an admin payload and a member payload
884
+ gave every member either the admin copy (when payload 1 was unchipped) or
885
+ nothing at all (when payload 1 was chipped to admins). An integration hit the
886
+ second shape on 0.7.13 and concluded config-driven segmentation was inert. Plan
887
+ 233 TASK-7 had already made the chip predicate real — but the predicate was only
888
+ ever asked about one payload.
889
+
890
+ **Landed in.** 0.10.4 (all three ports, plus `@revt-eng/core` 0.1.326).
891
+
892
+ **Fail-closed in.** 0.10.4 — the same release. There is no tolerance window:
893
+ a user who matches no payload now receives `visible: false` with
894
+ `segment_target_mismatch` rather than payload 1's content.
895
+
896
+ **Proving test.** `tests/parity/fixtures/segment_chip_payload_selection.json`
897
+ (cross-language), plus `local-resolver-payload-selection.test.ts`,
898
+ `server-python/tests/placements/test_payload_selection.py` and
899
+ `server-rust/tests/payload_selection.rs` — each exercising **both** the slot and
900
+ the direct-lookup path, because gating one and not the other is exactly the back
901
+ door plans 138 and 233 each had to close.
902
+
903
+ **Who is affected.** Any Playbook authoring more than one payload on a
904
+ placement where payload 1 is not the payload a given user matches. Those users
905
+ now see the payload their chips select — which is the fix, and is a live
906
+ behaviour change for such configs. Single-payload placements are unaffected.
907
+
908
+ ---
909
+
910
+ ## 0.10.3
911
+
912
+ ### The Rust port gains the decision surfaces Python already shipped (BL-0145)
913
+
914
+ **What changed.** `RevTurbineCustomerSdk` on the Rust port (`revturbine`,
915
+ crates.io) now exposes the same decision surfaces the Python port exposes.
916
+ Nothing on the TypeScript or Python side changes; this is a Rust-only,
917
+ purely additive release.
918
+
919
+ | Added | Shape |
920
+ |---|---|
921
+ | `RevTurbineCustomerSdk::can(handle, context)` | The advertised alias of `check_entitlement`, matching the scaffold SDK function surface (canonical `checkEntitlement`, alias `can`). |
922
+ | `RevTurbineCustomerSdk::get_eligible_plans()` | `Vec<EligiblePlan>` — public, segment-eligible plan variations. |
923
+ | `RevTurbineCustomerSdk::get_eligible_addons()` | `Vec<EligibleAddon>` — the add-on twin. |
924
+ | `RevTurbineCustomerSdk::evaluate_trial_status(instances, now_iso, base_plan_handle, usage_balances)` | `TrialEvaluation`, reading `free_trial_rules` / `reverse_trial_rules` from the constructed Playbook. |
925
+ | `revturbine::format_currency_minor_units(amount, currency, locale)` | Free function, because Python exposes it as a module function. |
926
+ | `revturbine::plans` | New module carrying the catalog eligibility port and the formatter. |
927
+ | `UserContext::segment_ids` | Pre-resolved segment ids the catalog methods match against. |
928
+
929
+ `UserContext` gains a field but derives `Default`, so existing construction
930
+ with `..Default::default()` keeps compiling.
931
+
932
+ **Why it was invisible.** The cross-language parity gate was green on the
933
+ catalog and currency scenarios only because the Rust parity runner
934
+ *reimplemented* `eligible_catalog` and `format_currency_minor_units` inline
935
+ rather than calling the crate — so the comparison never touched shipped code,
936
+ and the crate could lack the capability entirely without a single fixture
937
+ going red. The runner now calls `revturbine::plans`, and the inline copies are
938
+ deleted; all 102 comparisons stay byte-identical.
939
+
940
+ ---
941
+
942
+ ## 0.10.2
943
+
944
+ ### Local mode resolves placements by slot id (BL-0119)
945
+
946
+ **What changed.** In `local_only` mode the browser SDK now resolves a placement
947
+ request against the Playbook's authored `placement_slots[]` registry.
948
+ `getPlacement({ slotId })` — with or without a `componentType` — previously
949
+ returned `null` for a slot the Playbook declared, and a controller/`usePlacement`
950
+ mount of that slot resolved to `placement_not_found`. Looking the same placement
951
+ up by **name** worked, which is what made this look like a config problem rather
952
+ than an SDK one.
953
+
954
+ Two things were missing, and both are now in place:
955
+
956
+ - **`registerSurfaceSlot()` adopts the declared template.** `surface_template_ids`
957
+ on the registered record is what puts the shared resolver on its slot branch,
958
+ where `trigger.slot_id` is matched. Without it the resolver fell back to direct
959
+ lookup, which is keyed by placement **name**/id and therefore can never match a
960
+ slot id — hence `placement_not_found`. An id passed explicitly at the call site
961
+ still wins; the mounting code knows what it renders.
962
+ - **`getPlacement()` falls through to the slot registry.** The local placement
963
+ cache only holds slots a decision has already run through, so a cold lookup
964
+ found nothing and stopped. It now derives the record from `placement_slots`,
965
+ matching on `id` and falling back to `surface_type` when only a component type
966
+ is given — the same derivation scaffold's headless
967
+ `LocalRuntime.slotRecordForConfig` has always performed. Registration on this
968
+ path is local only: reading a placement never writes a surface slot back to the
969
+ control plane.
970
+
971
+ This closes a browser-versus-headless divergence: the same Playbook decided
972
+ differently depending on which runtime read it. No public API change — no
973
+ signature moved, and nothing new is asked of the caller. A slot id that no
974
+ `placement_slots` entry declares still resolves to `null`, unchanged.
975
+
976
+ **Landed in** `0.10.2`. **Fail-closed in** n/a — a previously-`null` lookup now
977
+ returns the placement the Playbook authored.
978
+
979
+ **Proving test:** `web-sdk/local-slot-id-lookup.test.ts` — by-slot-id and
980
+ by-slot-id-plus-component-type both resolve, the controller path no longer
981
+ reports `placement_not_found`, an undeclared slot id still returns `null` and
982
+ gains no invented template, an explicit `surfaceTemplateIds` still wins, and the
983
+ by-name lookup that always worked keeps working (the positive control).
984
+
985
+ ---
986
+
987
+ ## 0.10.1
988
+
989
+ ### Treatment interactions carry the identified `account_id`
990
+
991
+ **What changed.** Every treatment interaction the SDK sends to
992
+ `/api/events/interactions` — the impression that writes a
993
+ `placement_presentations` row, plus dismiss / click / conversion — now carries
994
+ `account_id`, taken from the account the integration identified
995
+ (`identify(userId, { account_id })` / `setUserContext`). It was never sent at
996
+ all, so the ingest route's `account_id ?? user_id` fallback stamped a **user**
997
+ id into an **account** column. `placement_presentations.account_id` is a join
998
+ key, not a label: `monetization_funnel` matches it against account ids from
999
+ `events_clickstream` / `events_billing`, and every experiment summary pipe
1000
+ reads it when `analysis_unit='account'`. Those joins therefore matched only
1001
+ where a user id happened to equal an account id — and an account-grain
1002
+ experiment readout returned the user-grain n while looking perfectly valid.
1003
+
1004
+ Two details make it a usable key. The value is PII-redacted **identically to
1005
+ `/api/track`**, so an email-shaped account id becomes the same hash in
1006
+ `placement_presentations` and in `events_clickstream` and the funnel actually
1007
+ joins. And when no account was identified the field is **omitted**, never set
1008
+ to the user id — the route keeps its own fallback for SDKs that have not
1009
+ upgraded, but a wrong join key is worse than a missing one.
1010
+
1011
+ No public API change: nothing new is asked of the caller, and
1012
+ `RevTurbineTreatmentInteractionInput` is unchanged. Integrations that already
1013
+ pass `account_id` to `identify()` get correct account-grain analytics with no
1014
+ code change; integrations that never identify an account are unaffected on the
1015
+ wire and see the same route-side fallback as before.
1016
+
1017
+ **Landed in** `0.10.1`. **Fail-closed in** n/a — additive; nothing old is
1018
+ rejected.
1019
+
1020
+ **Proving test:** `web-sdk/interaction-wire-contract.test.ts` — "the account
1021
+ identity the analytics joins key on": the identified account reaches the wire
1022
+ distinct from `user_id`, an unidentified one is omitted rather than copied from
1023
+ `user_id`, an email-shaped account id matches the `/api/track` lane
1024
+ byte-for-byte, and a re-queued batch keeps the account that was acting when
1025
+ each interaction happened.
1026
+
1027
+ ---
1028
+
1029
+ ## 0.10.0
1030
+
1031
+ ### `publicKey` is the browser credential; `apiKey` is the server key
1032
+
1033
+ **What changed.** The browser init option for the ingest (public) key is
1034
+ `publicKey` — on `initRevTurbine`, `<RevTurbineProvider options>`, and the
1035
+ headless init alike. Every browser bearer (ingest, launched-Playbook delivery,
1036
+ user context, branding, trial status) uses that one key. `apiKey` now means
1037
+ one thing everywhere: the secret **server key**, as it always did on
1038
+ `@revturbine/sdk/server` — pass it from backend code only (a route handler
1039
+ using `@revturbine/sdk/headless` keeps using `apiKey`). The name states what the
1040
+ credential is at the call site: a security scanner or an agent that meets
1041
+ `publicKey` in a bundle knows it belongs there, where `apiKey` reads as a leak.
1042
+
1043
+ `apiKey` and `ingestPublicKey` are still accepted on a browser init as aliases
1044
+ of `publicKey` for one minor (precedence: `publicKey`, then `ingestPublicKey`,
1045
+ then `apiKey`). Using an alias without `publicKey` in a browser logs a one-time
1046
+ development warning naming `publicKey`; production builds are silent. On the
1047
+ browser `RevTurbineInitOptions` type, `apiKey` is now optional (it was required)
1048
+ and `ingestPublicKey` is `@deprecated`. Keyless local-only init is unchanged,
1049
+ and the plan 95 anonymous `sdk_init` beacon still fires only when no public key
1050
+ (`publicKey` or `ingestPublicKey`) was supplied.
1051
+
1052
+ Ruling: Kent, 2026-09-21, devkit PR #808 (closed).
1053
+
1054
+ **Landed in** `0.10.0`. **Fail-closed in** `0.11.0` — `ingestPublicKey` was
1055
+ removed from the options type and `apiKey` stopped being read as a browser
1056
+ credential (BL-0113); see the `0.11.0` entry above.
1057
+
1058
+ **Proving test:** `web-sdk/public-key-option.test.ts` — precedence, the single
1059
+ bearer across ingest and control-plane fetches, the browser-only warning, and
1060
+ the keyless local-only init. Type-level: `web-sdk/init-options-exactness.test-d.ts`
1061
+ accepts `publicKey` and rejects the `publickey` casing typo.
1062
+
1063
+ ### `endpoint` and `mode` are optional on `initRevTurbine` and `<RevTurbineProvider>`
1064
+
1065
+ **What changed.** `RevTurbineInitOptions.endpoint` defaults to
1066
+ `https://revturbine.com/app` (`DEFAULT_HOSTED_ENDPOINT`, exported) and
1067
+ `RevTurbineInitOptions.mode` defaults to `'snippet'` (`DEFAULT_SDK_MODE`,
1068
+ exported); `<RevTurbineProvider>` passes `'react'` when the app does not set it.
1069
+ Both were required; `mode` only ever labelled telemetry (`page_view.mode` and a
1070
+ diagnostics message), and every hosted integration used the same endpoint.
1071
+ Existing calls that pass them are unchanged. Local mode is unchanged (its
1072
+ placeholder defaults already applied). Additive, no version bump required.
1073
+
1074
+ **Landed in** `0.10.0`. **Fail-closed in** n/a — nothing old is rejected.
1075
+
1076
+ **Proving test:** `web-sdk/public-init.test.ts` — "defaults endpoint and mode
1077
+ for a hosted init that omits them".
1078
+
1079
+ ### Reason-code compatibility verification
1080
+
1081
+ Added a reviewed baseline for 27 entitlement and 21 placement reason values,
1082
+ checked against live SDK/core fixtures. Removal/rename controls exercise every
1083
+ protected value, and baseline changes require this changelog to change through
1084
+ the existing public-API gate. Custom provider reasons remain extensible and
1085
+ free-form diagnostics are excluded. This adds verification only; no runtime
1086
+ reason values or package versions change.
1087
+
1088
+ Proving tests: `web-sdk/reason-contract.test.ts` and
1089
+ `web-sdk/reason-contract-policy.test.ts`. The intentional-change procedure is
1090
+ in `docs/reason-code-contract.md`.
1091
+
1092
+ ## 0.9.3
1093
+
1094
+ ### Fixed
1095
+
1096
+ - Placement explanations use the same published segment-eligibility helper as
1097
+ decisions, keeping OR-within-segments and AND-with-plan behavior aligned.
1098
+ The explanation/decision matrix covers local and provider-backed Playbooks,
1099
+ unknown or missing segments, exact handle matching and plan-filter combinations.
1100
+
1101
+ ## 0.9.2
1102
+
1103
+ ### Public initializer user context
1104
+
1105
+ `await initRevTurbine({ user: { id, ...context }, ...options })` from
1106
+ `@revturbine/sdk` or `@revturbine/sdk/headless` now passes `id` only as the
1107
+ identity argument. Supported user context retains its existing behavior without
1108
+ the spurious unrecognized-context-key warning for `id`; genuinely unknown keys
1109
+ still warn. Public quick starts now show the awaited `SdkSession` and
1110
+ `session.sdk.getBranding()`. The separate synchronous core initializer is unchanged.
1111
+
1112
+ Proving tests: `web-sdk/public-init.test.ts` and the installed public-package
1113
+ runtime/type examples in `web-sdk/scripts/check-public-diagnostics.mjs`.
1114
+
1115
+ ## 0.9.1
1116
+
1117
+ ### Public changelog distribution
1118
+
1119
+ The maintained changelog is now included as `CHANGELOG.md` in the npm package
1120
+ and mirrored at the root of the public SDK repository. The docs Reference
1121
+ navigation links directly to that authoritative public file. The independent,
1122
+ hidden docs copy has been retired. The pre-0.3.0 history gap below remains;
1123
+ this release does not reconstruct it. No SDK runtime behavior changed.
1124
+
1125
+ ## 0.9.0
1126
+
1127
+ ### Placement eligibility follows current context after conversion
1128
+
1129
+ - **What changed** — Conversion remains an analytics event and no longer retires
1130
+ or temporarily suppresses a placement. Legacy conversion-owned browser state
1131
+ is ignored. Supply the updated UserContext after a plan change; Playbook
1132
+ targeting determines whether the offer still applies.
1133
+ - **What changed** — Fixed and Access Gate placements bypass dismissal, reminder
1134
+ and bare-click cooldowns. Dismiss and snooze close the current display; the
1135
+ next explicit trigger, refresh or mount evaluates again. This uses the resolved
1136
+ placement category, including when another category renders inside a Fixed slot.
1137
+ Other categories retain authored/default windows, and explicit system
1138
+ suppression and entitlement denial remain effective.
1139
+ - **Landed in** — `0.9.0` across JavaScript, Python and Rust.
1140
+ - **Fail-closed in** — Not applicable; no input shape was removed. Rust callers
1141
+ constructing `InteractionState` literals should supply the new optional
1142
+ `explicit_suppressed_until` field or use `..Default::default()`.
1143
+ - **Proving test** — `web-sdk/conversion-eligibility.test.ts`,
1144
+ `web-sdk/placements/FixedSurfaceSlot.dismissal.test.tsx`, and the shared
1145
+ `placement_category_interactions` parity fixture.
1146
+
1147
+ ## 0.8.9
1148
+
1149
+ ### JavaScript: development diagnostics survive package minification
1150
+
1151
+ - **What changed** — The SDK package preserves the environment check until the
1152
+ consuming application builds it. Previously the minified package baked in
1153
+ production mode, hiding the React initialization-failure banner, app-theme
1154
+ override warning and legacy-theme warning even in development applications.
1155
+ Production applications still suppress those development diagnostics;
1156
+ `initStatus` and required initialization-error reporting remain available.
1157
+ The existing fallback for browsers without `process` is preserved.
1158
+ - **Landed in** — `0.8.9`.
1159
+ - **Fail-closed in** — Not applicable; no decision or initialization policy changes.
1160
+ - **Proving test** — `web-sdk/scripts/check-public-diagnostics.mjs` installs the
1161
+ public tarball and runs Chromium against separate development, production
1162
+ and environment-preserving consumer bundles. The PR release-build gate
1163
+ retains the tarball, inventory, SHA-256 and observed diagnostics.
1164
+
1165
+ Python and Rust receive the matching version bump; their behavior is unchanged.
1166
+
1167
+ ## 0.8.8
1168
+
1169
+ Behavioural fixes in Python; a new decision surface in Rust.
1170
+
1171
+ ### Python: numeric coercion unified onto JS Number() semantics
1172
+
1173
+ - **What changed** — `parse_numberish`'s string branch used Python
1174
+ `float()`, which accepts forms JS `Number()` rejects (underscore
1175
+ separators: `"1_000"` scored 1000 here, NaN->0 in TypeScript — a
1176
+ candidate-flipping divergence in every selection scorer) and rejects
1177
+ forms JS accepts (`"0x10"` is 16). The one JS-semantics parser from the
1178
+ plan-233 segments port is promoted to `helpers.js_number` and every
1179
+ coercion site shares it. Ripple alignments: `Number("")` is 0 (the old
1180
+ is-None assertion pinned the float() behaviour), a whitespace-only
1181
+ `template_version` resolves to `"0"` exactly as TS does, and
1182
+ `superseded_versions` no longer fabricates a `"0"` entry from an empty
1183
+ string (mirrors TS's trim-then-filter).
1184
+
1185
+ ### Rust: the candidate-selection layer, ported
1186
+
1187
+ - **What changed** — `resolve_local_placement_from_candidates` plus the
1188
+ full helper set (category buckets, plan-53 two-stage tier-3 urgency,
1189
+ milestone supersession, category conflict suppression, server-order
1190
+ dominance) now exists in Rust (`placements/selection.rs`). Until now the
1191
+ Rust port selected by entry order only; competing-category selections
1192
+ had no third side, which is why plan 234 TASK-15's fixture was deferred.
1193
+ - **Proving test** — parity fixture `placement_selection_priority`
1194
+ (10 calls: cross-tier dominance, both tier-3 stages including a
1195
+ class-vs-proximity disagreement, retention/conversion tie, server_order
1196
+ skipping conflict suppression, fixed_only, milestone supersession, both
1197
+ REQ-5 coercion lanes, output_id tiebreak), byte-identical ts/py/rs on
1198
+ its first regen, plus mirrored port-local suites in Python and Rust.
1199
+
1200
+ ## 0.8.7
1201
+
1202
+ No runtime behaviour change in any port; the release carries new parity
1203
+ locks and one serde derive.
1204
+
1205
+ ### All ports: trial-status derivation and allocation scoping parity-locked
1206
+
1207
+ - **What changed** — two decision surfaces every runtime already evaluated
1208
+ gained their first cross-language locks. `evaluateTrialStatus` (the
1209
+ free/reverse trial-rule derivation): the TS golden vectors mirrored the
1210
+ Python unit suite by hand-maintained convention, and Rust was never in
1211
+ the mirror at all; the `trial_status_evaluation` fixture makes the
1212
+ agreement a byte-checked property. `computeConsumedPercent` (the spec
1213
+ 3.4 Pooling allocation-scoped grant selection): reachable from no
1214
+ placement fixture because the static provider has no grants lane; the
1215
+ `threshold_allocation_scoping` fixture drives every allocation label
1216
+ against a hierarchy whose levels carry DIFFERENT counters.
1217
+ - **A wire divergence caught on first regen** — the reverse-trial grants
1218
+ set serialized three different ways (TS `JSON.stringify(Set)` = `{}`
1219
+ with the contents silently lost; Python's canonicalizer dropped the key;
1220
+ Rust emitted the honest list). The runners now own the wire form: a
1221
+ sorted array on every side.
1222
+ - **Fail-closed in** — no evaluator changed; `TrialEvaluation` gained a
1223
+ `Serialize` derive in Rust (additive).
1224
+ - **Proving test** — parity fixtures `trial_status_evaluation` (6 calls;
1225
+ the usage-lane call was sharpened after the first cut put
1226
+ `trial_limit_type` on the RULE and every side agreed on `time` - the
1227
+ non-discriminating shape REQ-3 forbids; it rides the INSTANCE) and
1228
+ `threshold_allocation_scoping` (9 calls).
1229
+
1230
+ ## 0.8.6
1231
+
1232
+ One capability lands in the two ports; TypeScript is unchanged and ships this
1233
+ version for lockstep alone.
1234
+
1235
+ ### Python + Rust: targeting-state derivation ported (buildTargetingState)
1236
+
1237
+ - **What changed** — `LocalRuntime.build_targeting_state` raised
1238
+ NotImplementedError in Python (a REQ-14 deferral) and had no Rust port at
1239
+ all, so the derivation from a raw user context to the traits segment
1240
+ evaluation consumes existed only in TypeScript: segment parity proved
1241
+ "agrees given the same traits", never "agrees given the same user
1242
+ context". Both ports now carry the pure derivation
1243
+ (`core.user_context.build_targeting_state` / `user_context.rs`), byte-
1244
+ locked against the TS canonical.
1245
+ - **Also aligned red-first** — Python's dormant
1246
+ `configured_plan_name_from_exported_config` (no callers until now) still
1247
+ carried pre-plan-120 semantics: id matching, plan-object resolution, and
1248
+ a `_handle` suffix fallback. Plans resolve by `unique_handle` alone; the
1249
+ drift-pinning tests were corrected with the alignment.
1250
+ - **Fail-closed in** — identity rules are shadow-proof by construction:
1251
+ `plan_handle` is a reserved trait a `custom.plan_handle` can never
1252
+ impersonate, and no identity means the reserved trait is REMOVED and the
1253
+ effective plan omitted.
1254
+ - **Proving test** — parity fixture `targeting_state_derivation` (6 calls:
1255
+ identity precedence, shadow-proofing, plan-OBJECT fallback, the id that
1256
+ must resolve nothing, the no-identity removal, override-wins usage with a
1257
+ legacy bare-number entry), byte-identical ts/py/rs, plus the corrected
1258
+ unit matrices in both ports.
1259
+
1260
+ ## 0.8.5
1261
+
1262
+ One behavioural alignment, Python only. TypeScript and Rust are unchanged and
1263
+ ship this version for lockstep alone.
1264
+
1265
+ ### Python: category buckets and tier-3 urgency aligned to the plan-53 model
1266
+
1267
+ - **What changed** — this port's `category_bucket` carried the pre-plan-53
1268
+ map for months (trial=3, retention=5 where the canonical folds trial into
1269
+ priority tier 3 as bucket 2 and retention into the single discretionary
1270
+ bucket 4); its docstring cited source lines that had moved AND changed
1271
+ upstream. The map feeds the selection comparator, so a trial and a usage
1272
+ placement competing for one surface ordered STRICTLY here (usage always
1273
+ first) while the canonical ties them and applies the plan-53 two-stage
1274
+ urgency model — which this port also lacked. `tier3_class` is now ported
1275
+ and staged ahead of proximity in `resolve_local_placement_from_candidates`,
1276
+ the map matches the canonical, and the system-caps exemption boundary moved
1277
+ from the compensating `<= 3` to the canonical `<= 2` (the exempted category
1278
+ set is unchanged).
1279
+ - **Landed in** — `helpers.category_bucket` / `helpers.tier3_class`, the
1280
+ comparator in `placement_decision.py`, and the caps exemption boundary.
1281
+ - **Fail-closed in** — no gate changes; this is ordering. Exemptions and
1282
+ discretionary classification are set-identical before and after.
1283
+ - **Proving test** — `tests/placements/test_selection_tier3.py` (class
1284
+ ordering with class-vs-proximity DISAGREEMENT cases; mutation-checked:
1285
+ disabling the tier3 stage fails exactly the two stage-1 tests), plus the
1286
+ bucket-map matrix in `test_helpers.py` whose old strict `usage < trial`
1287
+ invariant was pinning the drift. The cross-language fixture for this
1288
+ surface lands with the Rust selection-layer port (plan 234 TASK-8).
1289
+
1290
+ ## 0.8.4
1291
+
1292
+ One behavioural alignment, Python only. TypeScript and Rust are unchanged and
1293
+ ship this version for lockstep alone.
1294
+
1295
+ ### Python: cap/cooldown maths consolidated onto the shared primitive
1296
+
1297
+ - **What changed** — `placement_decision.py` and `cap_enforcer.py` carried
1298
+ their own copies of the cap-window and cooldown arithmetic that TypeScript
1299
+ consolidated onto `evaluateCaps` (plan 233 TASK-15 / scaffold #351). Both
1300
+ now delegate to the new `cap_rules.evaluate_caps` port. Reading the bodies
1301
+ side by side surfaced two silent divergences, both aligned to the TS
1302
+ canonical: Fixed / Access-Gate categories are now exempt from per-payload
1303
+ caps in `check_placement_caps` (TS has exempted them since plan 167; this
1304
+ port capped them), and a cap deny now persists the UNFILTERED state — the
1305
+ old bodies trimmed `seen_at` to the tripping window (one comment claimed
1306
+ "Mirrors TS"; TS does no such trim), silently shrinking future week/month
1307
+ windows.
1308
+ - **Landed in** — `core/placements/cap_rules.py` (the primitive),
1309
+ `check_placement_caps` / `check_system_presentation_caps` /
1310
+ `CapEnforcer.enforce` (the delegations).
1311
+ - **Fail-closed in** — verdicts only ever tighten or stay: the exemption
1312
+ affects categories that are user-prompted by design, and the state
1313
+ alignment preserves more history, never less.
1314
+ - **Proving test** — `tests/placements/test_cap_delegation.py`: a 12-case
1315
+ characterization matrix green before AND after the refactor, the two
1316
+ aligned behaviours run RED against the pre-refactor body, and a mutation
1317
+ of the shared window arithmetic fails four tests across both entry points
1318
+ (the shared maths is reached, not shadowed).
1319
+
1320
+ ## 0.8.3
1321
+
1322
+ One behavioural fix, Rust only. TypeScript and Python are unchanged and ship
1323
+ this version for lockstep alone.
1324
+
1325
+ ### Rust: a confirmed conversion did not retire the placement
1326
+
1327
+ - **What changed** — the Rust port carried the full ImpressionHistory
1328
+ machinery (`record_conversion`, `is_hidden_sync`, the retired cache) since
1329
+ the port landed, but its placement resolver never consulted it. A user who
1330
+ completed a placement's CTA — a confirmed conversion — kept seeing that
1331
+ placement in this port alone, while TypeScript core
1332
+ (`local-resolver.ts:610-612`, `:726`) and Python both hide it permanently.
1333
+ This is the defect class plan 167's unshipped follow-up became as a
1334
+ customer P1: paying customers shown the upsell they already bought.
1335
+ - **Landed in** — the retirement gate is wired at both decision lanes: the
1336
+ candidate filter (before the trigger gates, mirroring the TS order) and
1337
+ the direct-lookup path, which now answers `placement_retired`, keyed by
1338
+ the output's `rule_id` exactly as TS core and Python key it.
1339
+ - **Fail-closed in** — the gate only ever hides; an empty or cold history
1340
+ changes nothing, and a placement with no recorded conversion is untouched.
1341
+ - **Proving test** — parity fixture `placement_conversion_retires`
1342
+ (visible -> convert -> `placement_retired`, with an untouched control
1343
+ staying visible, byte-identical across ts/py/rs; mutation-checked: making
1344
+ the Rust gate inert diverges the snapshot) and
1345
+ `server-rust/tests/local_runtime.rs`
1346
+ (`a_confirmed_conversion_permanently_retires_the_placement`).
1347
+
1348
+ ## 0.8.2
1349
+
1350
+ One behavioural fix, Rust only. TypeScript and Python are unchanged and ship
1351
+ this version for lockstep alone.
1352
+
1353
+ ### Rust: provider-path rule selection was source-order, not most-permissive
1354
+
1355
+ - **What changed** — on the provider path (`check_entitlement` with a rules
1356
+ provider, the path a static config registers), the Rust port's §2.6.5
1357
+ most-permissive selection scored each rule snapshot's root object instead
1358
+ of its `fields` sub-object, where the adapter actually nests the score keys
1359
+ (`limit_value`, `allowance`, `included_count`, `enabled`). Every rule
1360
+ scored 0, so the deterministic tie-break — earliest in source order —
1361
+ silently decided instead. Concretely: an entitlement carrying both a seat
1362
+ rule (listed first) and a `usage_limit` rule at limit 2 returned **allowed**
1363
+ at `used: 3` where TypeScript and Python return limited
1364
+ (`usage_limit_reached`). Whether you were affected depends on rule order in
1365
+ your Playbook, which is what made this silent. The ExportedConfig fallback
1366
+ path (`derive_local_entitlement_from_configured_rules`) was NOT affected —
1367
+ it passes the merged type-fields bag and scored correctly.
1368
+ - **Landed in** — `0.8.2`.
1369
+ - **Fail-closed in** — `0.8.2` (same release; no tolerated window).
1370
+ - **Proving test** — parity fixture `entitlement_rule_seat_included_count`
1371
+ (byte-locked ts ≡ py ≡ rs; diverged on exactly the shadowed-limit call until
1372
+ the fix) and `most_permissive_scores_the_fields_sub_object_not_the_snapshot_root`
1373
+ (Rust unit, mutation-checked). The pre-existing Rust unit test had authored
1374
+ its score key at the snapshot root — a shape the adapter never emits — and
1375
+ passed over the bug; it now uses the production shape.
1376
+
1377
+ ---
1378
+
1379
+ ## 0.8.1
1380
+
1381
+ One behavioural fix, Python and Rust only. The TypeScript SDK is unchanged and
1382
+ ships this version for lockstep alone.
1383
+
1384
+ ### Python/Rust: cross-dimension segment AND no longer degrades to flat OR
1385
+
1386
+ - **What changed** — the config evaluator's segment→dimension lookup was keyed
1387
+ by the segment's `id` in the Python and Rust ports, where the TypeScript
1388
+ canonical keys it by `handle`. Rule `segment_ids` are handle-valued, so on
1389
+ any Playbook whose segment ids differ from their handles — every real
1390
+ export — the ports' lookups all missed, every rule segment collapsed into
1391
+ the uncategorised bucket, and a rule spanning two dimensions (which must
1392
+ require BOTH, intra-dimension OR + cross-dimension AND) matched on EITHER.
1393
+ Concretely: an entitlement rule scoped to `emea` (region) AND `admins`
1394
+ (role) **granted** a user who was only in `emea`. It now denies, matching
1395
+ the TypeScript SDK. If your integration passes a non-empty user segment set
1396
+ to `derive_local_entitlement_from_configured_rules` and relies on a
1397
+ cross-dimension rule, users who matched only one dimension lose the grant
1398
+ on upgrade — that is the fix working. The built-in runtime fallback path
1399
+ passes an empty segment set and is unaffected.
1400
+ - **Landed in** — `0.8.1`.
1401
+ - **Fail-closed in** — `0.8.1` (same release; no tolerated window).
1402
+ - **Proving test** — parity fixtures `entitlement_segment_dimensions` /
1403
+ `entitlement_segment_no_dim_bucket` (byte-locked TS ≡ Python ≡ Rust; the
1404
+ regen diverged on exactly the cross-dimension calls until the fix), plus
1405
+ `TestSegmentDimensionLookup` (Python) and `cross_dimension_and_*` (Rust).
1406
+
1407
+ ## 0.8.0
1408
+
1409
+ Plan 233 remediated four escalated defect families, and this is the release that
1410
+ delivers them. Everything below sat merged on `main` and unpublished for the
1411
+ length of that plan — 20 commits, twelve breaking changes — because the version
1412
+ never moved off `0.7.13`.
1413
+
1414
+ **Read this section before upgrading from `0.7.x`.** Three of these change live
1415
+ behaviour rather than only types: segment-targeted payloads stop showing to
1416
+ users outside the segment, `sdk.dismiss()` starts writing suppression where it
1417
+ previously did nothing, and a converted placement stops coming back. Each is the
1418
+ fix working, and each is visible to your users the moment you upgrade.
1419
+
1420
+ ### The `tenantId` init option is now the authority over a Playbook's `tenant_id`
1421
+
1422
+ - **What changed** — the Playbook's `tenant_id` used to win, and the init option
1423
+ was a fallback applied only to legacy artifacts. That combination killed every
1424
+ integration that served a canonical Playbook *and* passed the tenant at init:
1425
+ the artifact had no `tenant_id` to win with, and the option was withheld
1426
+ because the artifact was canonical, so init threw. The init option now wins.
1427
+ The artifact's `tenant_id` is a **guard** — it exists so the CLI can refuse to
1428
+ upload a config to the wrong tenant — and on a mismatch the SDK warns, naming
1429
+ both values, and proceeds. It never fails init.
1430
+ - **Landed in** — `0.8.0`
1431
+ - **Fail-closed in** — n/a. This change makes init *succeed* where it used to
1432
+ throw. The old behaviour was the failure.
1433
+ - **Proving test** — `web-sdk/config-target-precedence-init.test.ts`
1434
+
1435
+ ### Unrecognized and missing init options are compile errors
1436
+
1437
+ - **What changed** — init options are exact-checked. A typo (`apikey` for
1438
+ `apiKey`) or a stale option is a type error rather than a value the SDK
1439
+ silently ignores. This catches the case TypeScript's excess-property check
1440
+ never saw: options assembled in a variable, or in an un-annotated `useMemo`,
1441
+ which is exactly how React integrations build them.
1442
+ - **Landed in** — `0.8.0`
1443
+ - **Fail-closed in** — same. Unknown keys were ignored at runtime before and
1444
+ still are; what changed is that you cannot write them.
1445
+ - **Proving test** — `web-sdk/init-options-exactness.test-d.ts`
1446
+
1447
+ ### A truncated Playbook is a compile error at the `localRuntime` boundary
1448
+
1449
+ - **What changed** — `UnvalidatedConfigArtifact` was `Record<string, unknown>`
1450
+ and now structurally requires the body arrays (`plans`, `entitlements`,
1451
+ `entitlement_rules`, `segments`, `content_ui_paths`). A Playbook missing one is
1452
+ now caught by `tsc` instead of throwing at init.
1453
+ - **Who this breaks** — only a caller who **explicitly annotated** their artifact
1454
+ `Record<string, unknown>`; that must now be narrowed. A JSON module import and
1455
+ a fetched artifact (`await res.json()`, typed `any`) both still compile with no
1456
+ cast.
1457
+ - **Landed in** — `0.8.0`
1458
+ - **Fail-closed in** — same version, at compile time. Runtime validation was
1459
+ already fail-closed and is unchanged.
1460
+ - **Proving test** — `web-sdk/playbook-structural.test-d.ts`
1461
+
1462
+ ### Init failure is observable without an SDK instance
1463
+
1464
+ - **What changed** — when init fails the provider used to log and render children
1465
+ anyway, so the SDK simply never started and every diagnostic probe was
1466
+ unreachable *in precisely the failure mode that matters most*. `initStatus` is
1467
+ now on the React context and is readable when the SDK instance is `null`, with
1468
+ a non-empty `remediation` string on every init-path error.
1469
+ - **Landed in** — `0.8.0`
1470
+ - **Fail-closed in** — same.
1471
+ - **Proving test** — `web-sdk/react/RevTurbineProvider.init-status.test.tsx`
1472
+
1473
+ ### The rendered theme resolves through the branding ladder
1474
+
1475
+ - **What changed** — `RevTurbineProvider` read `playbook?.theme` directly, so the
1476
+ `branding` init option never reached `useRevTurbineTheme()`. `getBranding()`
1477
+ and the rendered theme disagreed; that divergence *was* the defect. Both now
1478
+ resolve from the same rung.
1479
+ - **Landed in** — `0.8.0`
1480
+ - **Fail-closed in** — same.
1481
+ - **Proving test** — `web-sdk/react/RevTurbineProvider.branding-ladder.test.tsx`
1482
+
1483
+ ### `RevTurbineThemeProvider` merges partial themes, and an app-mounted one wins
1484
+
1485
+ - **What changed** — a partial theme replaced the defaults wholesale instead of
1486
+ merging, so supplying one token dropped every other. Partial themes now merge.
1487
+ Separately: if your app mounts its own `RevTurbineThemeProvider`, it wins over
1488
+ the SDK's internal default, and a conflict warns in development.
1489
+ - **Landed in** — `0.8.0`
1490
+ - **Fail-closed in** — same.
1491
+ - **Proving test** — `web-sdk/theme/ThemeContext.partial.test.tsx`,
1492
+ `web-sdk/react/RevTurbineProvider.theme-nesting.test.tsx`
1493
+
1494
+ ### Light/dark schemes no longer require re-initializing the SDK
1495
+
1496
+ - **What changed** — additive. A `colorScheme` option and `setColorScheme()` swap
1497
+ palettes while keeping SDK instance identity stable. The previous workaround
1498
+ was re-initializing the SDK, which discarded its state.
1499
+ - **Landed in** — `0.8.0`
1500
+ - **Fail-closed in** — n/a, additive.
1501
+ - **Proving test** — `web-sdk/react/RevTurbineProvider.color-scheme.test.tsx`
1502
+
1503
+ ### `segment_chips` are enforced in the decision path
1504
+
1505
+ - **What changed** — payload `segment_chips` were evaluated by
1506
+ `explainPlacementDecision` but **not** by the decision path itself, so the
1507
+ probe and the decision disagreed and a segment-targeted payload could show to a
1508
+ user outside the segment. Both now call one shared helper, in every port.
1509
+ - **Who this breaks** — an integration relying, knowingly or not, on
1510
+ segment-targeted payloads showing to everyone will now see them correctly
1511
+ restricted. **There is no grace period**: this was ruled a bug, not a behaviour
1512
+ change.
1513
+ - **Landed in** — `0.8.0`
1514
+ - **Fail-closed in** — same version, and in all three runtimes together.
1515
+ - **Proving test** — `tests/parity/fixtures/segment_chip_targeting.json` and
1516
+ `tests/parity/fixtures/segment_evaluation.json`, run byte-identical across
1517
+ TypeScript, Python and Rust.
1518
+
1519
+ ### Dismissal actually suppresses, at the authored window
1520
+
1521
+ - **What changed** — two defects. The interaction-state **read** omitted
1522
+ `treatmentId` while the **write** included it, and the controllers always
1523
+ populate it, so the keys could never match and dismissal suppression was dead
1524
+ for every React integration. And the window was a hardcoded 24-hour literal
1525
+ passed explicitly at every call site, which made both the authored
1526
+ `cooldown_after_dismiss_days` and the 7-day default unreachable. The window now
1527
+ resolves: explicit caller value → **authored config** → SDK default.
1528
+ `remind_later_minutes` resolves independently, so "remind me" no longer means
1529
+ "hide for a week".
1530
+ - **Who this breaks** — dismissal now suppresses where it previously did nothing.
1531
+ An integration that layered its own suppression on top may double-suppress.
1532
+ - **Landed in** — `0.8.0`
1533
+ - **Fail-closed in** — same. Note the *shape* of this one: it shipped in plan 167
1534
+ with a proving test that dismissed **without** a `treatmentId`, so both keys
1535
+ fell back to `'default'`, matched, and the feature appeared to work for a
1536
+ month.
1537
+ - **Proving test** — `web-sdk/customer-side-cooldown-suppression.test.ts`
1538
+
1539
+ ### A dismissal is no longer treated as "no match"
1540
+
1541
+ - **What changed** — `FixedSurfaceSlot` rendered its `fallback` whenever
1542
+ `!visible`, and dismissing sets `visible: false` — so closing a placement put
1543
+ the fallback back in the same space, a ghost replacing the thing just
1544
+ dismissed. A slot now distinguishes the two: no match still renders `fallback`;
1545
+ a dismissal renders nothing. `FixedSurfaceSlot` also gains `onDismissed`, and
1546
+ `MessageSurfaceSlot`'s `onDismissed` — a public prop whose handler was built
1547
+ and then discarded, so it could never fire — now works.
1548
+ - **Who this breaks** — a host working around the ghost (one integration used a
1549
+ `MutationObserver`) can remove the workaround. `MessageSurfaceSlot`'s
1550
+ `onDismissed` goes from never firing to firing.
1551
+ - **Landed in** — `0.8.0`
1552
+ - **Fail-closed in** — same.
1553
+ - **Proving test** — `web-sdk/placements/FixedSurfaceSlot.dismissal.test.tsx`
1554
+
1555
+ ### `convert()` retires the placement instead of only reporting it
1556
+
1557
+ - **What changed** — `convert()` emitted a `placement_interaction` event and
1558
+ stopped: it never wrote the terminal state, so **a user who converted kept
1559
+ being shown the upsell they had just paid for**. `dismiss()` and `snooze()` had
1560
+ the identical report-but-never-act shape. All three now route through
1561
+ `trackTreatmentInteraction`.
1562
+ A second defect sat behind the same symptom: nothing in the decision path ever
1563
+ *read* the terminal state. `isRetired` was written by `recordConversion` and
1564
+ had no caller, so a 5-minute transient window was the only thing hiding a
1565
+ converted placement — the upsell returned five minutes after checkout even
1566
+ through the controller path. `getPlacementDecision` now checks retirement and
1567
+ answers `retired_by_conversion`.
1568
+ - **Who this breaks** — `sdk.dismiss()` now writes suppression state where it
1569
+ previously did nothing.
1570
+ - **Landed in** — `0.8.0`
1571
+ - **Fail-closed in** — same.
1572
+ - **Proving test** — `web-sdk/convert-retires-placement.test.ts`
1573
+
1574
+ ### New: a slot-inventory probe
1575
+
1576
+ - **What changed** — additive. `sdk.getRegisteredSlots()` returns the slots this
1577
+ app has mounted, and `sdk.diagnoseSlotInventory()` diffs them against the
1578
+ Playbook's placement triggers in both directions. A placement targeting a slot
1579
+ no code mounts **can never show** and nothing reported it; a config-side audit
1580
+ cannot see call sites.
1581
+ - **Landed in** — `0.8.0`
1582
+ - **Fail-closed in** — n/a, additive and diagnostic.
1583
+ - **Proving test** — `web-sdk/slot-inventory-probe.test.ts`
1584
+
1585
+ ---
1586
+
1587
+ ## 0.7.0
1588
+
1589
+ ### `soft_block` → `block_with_upsell`
1590
+
1591
+ - **What changed** — the enforcement mode `soft_block` was renamed to
1592
+ `block_with_upsell`, and with it the reason codes the SDKs return:
1593
+ `usage_limit_reached_soft_block` → `usage_limit_reached_block_with_upsell`, and
1594
+ `credit_balance_exhausted_soft_block` → `credit_balance_exhausted_block_with_upsell`.
1595
+ **Any integration branching on the old reason string stops matching.**
1596
+ - **Landed in** — `0.7.0`
1597
+ - **Fail-closed in** — n/a. Behaviour is unchanged, and `soft_block` is still
1598
+ accepted wherever it appears in an already-compiled Playbook, decoding to
1599
+ exactly the decision it always made. Existing payloads keep working without
1600
+ recompilation; only the **string you match on** moved.
1601
+ - **Proving test** — the cross-language parity corpus (`tests/parity/`), which
1602
+ asserts the reason codes byte-identically across all three runtimes.
1603
+
1604
+ ---
1605
+
1606
+ ## 0.6.0
1607
+
1608
+ ### The server-side decision methods are gone
1609
+
1610
+ - **What changed** — removed `RevTurbineServer.evaluate` / `getPlacement` /
1611
+ `checkEntitlement` / `can` / `getTrialStatus` (Node), and the whole
1612
+ `revturbine_server` module (Python).
1613
+ - **Why the breakage is nominal** — every removed method called a hosted decision
1614
+ endpoint that no longer exists, so each had been returning a **network error**
1615
+ since that endpoint was deleted, in two languages, unnoticed — because the
1616
+ cross-port gate only ever asked whether a method of that *name* existed.
1617
+ - **What stays** — `RevTurbineServer` itself, and `createClientSession()`, which
1618
+ mints the browser-safe token the client SDK's `clientSession` callback
1619
+ consumes. An integration using the class only for that is unaffected.
1620
+ - **Replacement** — evaluation is a pure function of (user context, Playbook) and
1621
+ runs in the SDK. Use `LocalEvaluationServer` (Node) or `RevTurbineCustomerSdk`
1622
+ (Python).
1623
+ - **Landed in** — `0.6.0`
1624
+ - **Fail-closed in** — effectively before `0.6.0`: the methods were already
1625
+ erroring. `0.6.0` made the failure a compile/import error instead of a runtime
1626
+ one.
1627
+ - **Proving test** — `tests/no-decision-endpoint.test.ts`
1628
+
1629
+ ---
1630
+
1631
+ ## 0.5.0
1632
+
1633
+ ### The access gate denies during server rendering
1634
+
1635
+ - **What changed** — the gate is constructed inside a `useEffect`, and effects do
1636
+ not run during SSR. So on the server there was no gate at all: `isLoading` read
1637
+ false, `result` was null, `denied` computed false — and the gate emitted its
1638
+ **children**. The server-rendered HTML for a gated feature was literally the
1639
+ paid affordance. It now denies until a real verdict exists.
1640
+ - **Why this one mattered** — it is a fail-open in the one place the client
1641
+ cannot correct it. A crawler or a reader that never runs JS sees the paid
1642
+ content.
1643
+ - **Landed in** — `0.5.0`
1644
+ - **Fail-closed in** — `0.5.0`. This *is* the fail-closed change.
1645
+ - **Proving test** — `web-sdk/placements/AccessGateSurfaceSlot.ssr.test.tsx`
1646
+
1647
+ ---
1648
+
1649
+ ## 0.4.0
1650
+
1651
+ ### An unresolvable plan identity denies in Python and Rust
1652
+
1653
+ - **What changed** — the headless ports granted where TypeScript denied when a
1654
+ user context carried no resolvable plan identity. The ports now deny, matching
1655
+ TypeScript.
1656
+ - **Landed in** — `0.4.0`
1657
+ - **Fail-closed in** — `0.4.0` for Python and Rust; TypeScript already denied.
1658
+ **The window between `0.3.0` and `0.4.0` is the expensive one** — a context
1659
+ that failed closed in TS silently granted in the other two runtimes.
1660
+ - **Proving test** — the cross-language parity corpus, which is what surfaced the
1661
+ divergence: the gate had been green while `deriveLocalEntitlementFromConfiguredRules`
1662
+ sat unreached by any fixture.
1663
+
1664
+ ---
1665
+
1666
+ ## 0.3.0
1667
+
1668
+ This release is where the `plan: { id }` → `plan_handle` migration landed. It was
1669
+ a **minor** bump carrying breaking changes, authorised because there were no
1670
+ active tenants and only in-org code referenced the changed surfaces.
1671
+
1672
+ ### `plan: { id }` → `plan: { handle }` / `plan_handle`
1673
+
1674
+ - **What changed** — a plan's matching identity is its `unique_handle`.
1675
+ `plan.id` is DB-internal and no longer participates in matching anywhere, in
1676
+ any of the three runtimes.
1677
+ - **Symptom if you do not migrate** — a context whose only plan signal is
1678
+ `plan.id` now matches **no** plan-targeted rule and fails closed. It does not
1679
+ error; it simply has no plan.
1680
+ - **Landed in** — `0.3.0`
1681
+ - **Fail-closed in** — `0.3.0`.
1682
+ - **Proving test** — `web-sdk/user-context-exactness.test-d.ts` plus the
1683
+ plan-identity parity fixtures
1684
+ (`tests/parity/fixtures/entitlement_plan_identity_is_handle.json`,
1685
+ `entitlement_no_plan_identity_denies.json`).
1686
+
1687
+ ### `identify()` no longer accepts an arbitrary traits bag
1688
+
1689
+ - **What changed** — unknown top-level context keys are a compile error.
1690
+ Free-form values go under `custom`, via `update()`.
1691
+ - **Landed in** — `0.3.0`
1692
+ - **Fail-closed in** — `0.3.0`, at compile time.
1693
+ - **Proving test** — `web-sdk/user-context-exactness.test-d.ts`
1694
+
1695
+ ### `local_runtime_default_allow` → `entitlement_not_in_playbook`
1696
+
1697
+ - **What changed** — the reason code was renamed with **no deprecated alias**,
1698
+ and the headless runtimes' terminal fallback now **denies** where it used to
1699
+ grant.
1700
+ - **Landed in** — `0.3.0`
1701
+ - **Fail-closed in** — `0.3.0`. The rename and the fail-closed flip shipped
1702
+ together, deliberately: the old name described a grant, so keeping it as an
1703
+ alias would have described the new behaviour incorrectly.
1704
+ - **Proving test** — the fail-closed suites in `web-sdk/` and the corresponding
1705
+ Python and Rust port suites.
1706
+
1707
+ ---
1708
+
1709
+ ## Before 0.3.0
1710
+
1711
+ Versions `0.1.0` through `0.2.91` predate this changelog. Breaking changes in
1712
+ that range are recorded only in commit history and release notes. If you are
1713
+ upgrading from `0.2.x`, read the `0.3.0` section first — it is the release that
1714
+ changed plan identity, and it is the one most likely to affect you.