@revturbine/sdk 0.11.13 → 0.11.17

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
@@ -47,6 +47,687 @@ also require a changelog entry.
47
47
 
48
48
  ---
49
49
 
50
+ ## 0.11.17
51
+
52
+ ### Checkout metadata helper, `checkout_started`, and decision context on interactions (plan 282 TASK-9, BL-0432)
53
+
54
+ **What changed.** Three additive changes to the browser SDK (`@revturbine/sdk`);
55
+ no existing signature narrows.
56
+
57
+ - **`checkoutMetadata(outputId)`** (new, `@public`) returns the Stripe Checkout
58
+ `revturbine_*` metadata bag for an output this SDK decided — user, account,
59
+ placement, decision, output, rule and target plan — plus
60
+ `client_reference_id` (the user id). Pass `metadata` as both the session's
61
+ `metadata` and `subscription_data.metadata`; the mirror is what reaches each
62
+ invoice's `parent.subscription_details.metadata`. A key the SDK does not hold
63
+ is absent, never empty; `null` for an output this SDK did not decide. New
64
+ types `RevTurbineCheckoutMetadata` and `RevTurbineCheckoutMetadataBag`.
65
+ - **`checkout_started`** (taxonomy v6, `@revt-eng/schema` 0.1.381) is emitted on
66
+ a `cta_clicked` whose CTA opens a plan picker or pricing page
67
+ (`CHECKOUT_STARTED_CTA_ACTION_TYPES`: `navigate_to_plans`, `view_plans`) with
68
+ `{ placement_id, payload_id, decision_id, plan_handle, rule_handle }`. Never
69
+ for `open_checkout_modal` — that CTA opens checkout directly ("on click"), so
70
+ its started count is its clicked count.
71
+ - **`trackTreatmentInteraction`** accepts optional `decisionId`,
72
+ `segmentHandles` and `segmentIds` (`RevTurbineTreatmentInteractionDecisionContext`;
73
+ the parameter type is now `RevTurbineTreatmentInteractionRequest`, a
74
+ superset of the previous `RevTurbineTreatmentInteractionInput`) and sends
75
+ them as `decision_id` / `segment_handles` / `segment_ids`. Segments are
76
+ handles: `segment_handles` is the effective set scaffold's `evaluateSegments`
77
+ built plus any hosted-resolved slugs — the analytics join key — and
78
+ `segment_ids` carries only the minted ids a hosted context supplied
79
+ (telemetry; omitted entirely in local mode). When not given, they are filled
80
+ from the output this SDK indexed for `payloadId`; a caller that gives none
81
+ and names no indexed output sends exactly the row it sent before. The React
82
+ controller's clicks and the output-addressed `convert()` / `dismiss()` /
83
+ `snooze()` carry them.
84
+
85
+ **Landed in** 0.11.17. **Fail-closed in** n/a — additive. The headless ports
86
+ (Python, Rust, `server-node`) carry only the re-vendored generated types for
87
+ the 0.1.381 pin; the cross-language parity corpus is untouched.
88
+
89
+ **Docs.** `guides/billing.mdx` "Where checkout fits" now documents the bag, the
90
+ `subscription_data.metadata` mirror, `client_reference_id`, and that Customer
91
+ metadata cannot be set from Checkout.
92
+
93
+ **Proving tests.** `web-sdk/checkout-metadata.test.ts`,
94
+ `web-sdk/checkout-started-emit.test.ts`,
95
+ `web-sdk/interaction-decision-context.test.ts`.
96
+
97
+ ### `update({ usage })` now reaches usage and credit threshold placements and their usage tokens (BL-0402, BL-0409)
98
+
99
+ **What changed.** A behaviour fix in the browser SDK (`@revturbine/sdk`); no
100
+ signature changes. `update({ usage })` / `updateUsage()` wrote only the SDK's
101
+ private usage balances. `can()` and `getUsage()` read those balances, but the
102
+ placement resolver built its usage state from the user context's `usage`
103
+ entries alone, and took each entry's `limit` only from a per-entry field
104
+ (default `0`). The shared threshold gate fails closed on a missing or zero
105
+ limit. So `usage_threshold` and `credit_threshold` placements (the 80% usage
106
+ banner, the low-credits warning) never fired for app-reported usage, and
107
+ `{{usage_current}}` / `{{usage_limit}}` / `{{usage_remaining}}` /
108
+ `{{usage_percent}}` stayed stale or unresolved, while the gate and the meter
109
+ moved.
110
+
111
+ Placements now read one usage view, the same one `getUsage()` reports:
112
+
113
+ - **Used**: the balance the app reported through `update({ usage })` /
114
+ `updateUsage()` / `identify(…, { usage })` wins (the most recent write),
115
+ else the context entry's `amount`.
116
+ - **Limit**: the context entry's own `limit` wins, else the Playbook
117
+ allowance for the user's **current plan** (the plan-scoped `usage_limit`
118
+ rule's `limit_value` or `credits` rule's `allowance_value`, which is the
119
+ `limit` `getUsage()` already returned). An `unlimited` or absent allowance
120
+ means no threshold: the placement does not fire.
121
+ - **Tokens**: the SDK now fills the usage token family in decision content
122
+ from the same view. It fills `{{usage_current}}` whenever usage is known.
123
+ It fills `{{usage_limit}}`, `{{usage_remaining}}` and `{{usage_percent}}`
124
+ only when a positive limit is known, so an unlimited allowance leaves
125
+ those tokens untouched instead of rendering `0`. The `<unit>_usage_*`
126
+ forms and the aliases (`current_usage`, `current_limit`,
127
+ `remaining_usage`) are filled too. A registered entitlements provider
128
+ still supplies its own usage state, as before.
129
+
130
+ `can()` and `getUsage()` are unchanged. The headless server ports (Python,
131
+ Rust, `server-node`) take usage counters from the caller and are unaffected;
132
+ the cross-language parity corpus is untouched.
133
+
134
+ **Docs.** `tutorials/usage-quota-meter.mdx` and
135
+ `tutorials/low-credits-warning.mdx` now use the canonical flat Playbook rule
136
+ shape (`kind` + `limit_value` / `allowance_value`, handle-valued ids). The
137
+ old nested `type_fields` shape resolved no allowance, so neither tutorial's
138
+ banner could fire. The expected-behaviour table now matches what the SDK
139
+ does: the banner shows from 80%, and at 100% `can('api_calls')` denies.
140
+
141
+ **Proving test.** `web-sdk/usage-reaches-placements.test.ts`.
142
+
143
+ ### App-supplied trial context now reaches trial placements, trial tokens and reverse-trial grants (BL-0403)
144
+
145
+ **What changed.** A behaviour fix in the browser SDK (`@revturbine/sdk`); no
146
+ signature changes. `user.trial` at init, `update({ trial })`,
147
+ `setUserContext({ trial })` and a server action's `userContext` result all
148
+ merged into the user context's `trial`, but neither trial reader looked there.
149
+ The placement resolver's plan state (which gates `trial_started`,
150
+ `trial_progress`, `trial_ending`, `trial_ended` and `trial_converted`
151
+ placements) and the reverse-trial entitlement grants read only the SDK's own
152
+ trial status, written by `localRuntime.initialData.trialStatus`, `hydrate()`,
153
+ `getTrialStatus()` and `setTrialInstances()`. So an app that supplied its
154
+ trial saw no trial placement fire, no reverse-trial grant apply, and
155
+ `{{trial_days_remaining}}` stay raw.
156
+
157
+ Both readers now use one trial view:
158
+
159
+ - **Sources.** The user context's `trial` (what the app supplies, plus the
160
+ authenticated client-context delivery, which merges its trial into the same
161
+ field) and the SDK trial status (`initialData.trialStatus`, `hydrate()`,
162
+ `getTrialStatus()`, `setTrialInstances()`).
163
+ - **Precedence: the most recent change wins**, the same rule the usage view
164
+ applies to reported balances (BL-0402). A server-delivered or hydrated
165
+ status wins when it arrives after the app's trial (an
166
+ `initialData.trialStatus` passed alongside init `user.trial` counts as
167
+ later). An app `update({ trial })` made after it wins over it. Re-sending
168
+ an unchanged trial is not a newer write, so passing the same trial on every
169
+ render never displaces a fresher server status. A source that was never
170
+ supplied does not compete.
171
+ - **Tokens.** The SDK now fills `{{trial_days_remaining}}` and
172
+ `{{trial_days_total}}` in decision content from the same plan state the
173
+ trial gate read. A token whose value is unknown is left untouched.
174
+ - **`getTrialStatus()`** in `local_only` mode with no `getTrialStatus`
175
+ resolver returns this trial view, the app-supplied trial included.
176
+
177
+ **React.** `<RevTurbineProvider>` no longer reports `trial`, `tiers`,
178
+ `payment_failed`, `payment_at_risk`, `instances`, `experiments`, `email_type`,
179
+ `derived_config_version`, `context_hash` or `derived_computed_at` in
180
+ `options.user` as "dropped unrecognized user-context key(s)" on mount. The SDK
181
+ constructor already merges them from `options.user`; the provider now forwards
182
+ only the identity fields `identify()` merges. Unknown keys and server-assigned
183
+ keys are still reported.
184
+
185
+ The headless server ports (Python, Rust, `server-node`) take an
186
+ already-derived trial status and are unaffected; the cross-language parity
187
+ corpus is untouched.
188
+
189
+ **Proving tests.** `web-sdk/trial-reaches-placements.test.ts`,
190
+ `web-sdk/react/RevTurbineProvider.identify-context.test.tsx`.
191
+
192
+ ### Trial state no longer leaks across users; app-supplied trials fire lifecycle triggers; `getUserContext()` reports trial and billing fields (BL-0417)
193
+
194
+ **What changed.** Behaviour fixes in the browser SDK (`@revturbine/sdk`)
195
+ that follow up BL-0403. No signature changes.
196
+
197
+ - **Reset and user switch.** `resetIdentity()` / `resetUserContext()`
198
+ cleared the user context but kept the SDK trial status (from
199
+ `initialData.trialStatus`, `hydrate()`, `getTrialStatus()` or
200
+ `setTrialInstances()`). They also kept which trial source was newest and
201
+ the last lifecycle stage. So the next user saw the previous user's trial
202
+ placements, reverse-trial grants and `{{trial_days_*}}` tokens, and a
203
+ trial-less next user could emit `trial_expired`. A reset now clears all of
204
+ that, and the next user starts with no trial. Identifying a **different**
205
+ user without a reset (`identify()`, `setUserContext({ id })`, or a
206
+ `hydrate()` payload for another user) now drops the previous user's trial
207
+ (both sources) and usage (reported balances and context `usage`) before
208
+ merging the new user's values. The new user's own values still apply. Going
209
+ from anonymous to identified, or re-identifying the same user, keeps what
210
+ is held. Reported usage was already cleared on reset; only the
211
+ switch-without-reset path leaked it.
212
+ - **Lifecycle triggers.** `trial_midpoint`, `trial_expiring` and
213
+ `trial_expired` fired only from `getTrialStatus()` and
214
+ `setTrialInstances()`. The SDK now also runs the lifecycle evaluation
215
+ against the resolved trial view after every change that can move it:
216
+ `user.trial` at init, `update({ trial })`, `setUserContext`, a server
217
+ action's context, the client-context delivery, `hydrate()`, a reset, and a
218
+ Playbook load that turns the triggers on. The dedupe has not changed: a
219
+ stage fires only when it differs from the last stage seen. So an unchanged
220
+ trial, or both sources reporting the same threshold, fires once.
221
+ `enableTrialAutoTriggers: false` still silences them.
222
+ - **`getUserContext()`** now includes `trial` (the resolved trial view, the
223
+ one placements and grants decide with), `tiers`, `payment_failed`,
224
+ `payment_at_risk` and `instances` when they are held. The snapshot is a
225
+ copy, so changing it does not change SDK state.
226
+
227
+ The headless server ports are unaffected, and the cross-language parity
228
+ corpus is untouched.
229
+
230
+ **Proving test.** `web-sdk/trial-reset-lifecycle.test.ts`.
231
+
232
+ ## 0.11.16
233
+
234
+ ### Build fix, part two: the npm declaration build resolves `openapi-fetch` too (no API change)
235
+
236
+ **What changed.** 0.11.15 fixed the bundle step, but the public npm publish
237
+ then failed one command later, in the declaration emit
238
+ (`tsc -p tsconfig.build.json`). It reported
239
+ `../server-node/client.ts(29,43): error TS2307: Cannot find module 'openapi-fetch'`,
240
+ for the same reason as before: TypeScript resolves the bare import by walking
241
+ up from `server-node/`, and the public repo installs the package only under
242
+ `web-sdk/`. `web-sdk/tsconfig.build.json` now maps `openapi-fetch` to
243
+ `./node_modules/openapi-fetch`.
244
+
245
+ **npm users:** the 0.11.14 changes first reach npm in 0.11.16. Neither 0.11.14
246
+ nor 0.11.15 was published to npm (both are on GitHub Packages, PyPI and
247
+ crates.io). There is no behavior change across 0.11.14 through 0.11.16.
248
+
249
+ **Landed in:** 0.11.16.
250
+ **Fail-closed in:** n/a (build only).
251
+ **Proving test:** the public repo's `release-npm.yml` build step for
252
+ `v0.11.16`. It was reproduced locally by hiding the root `openapi-fetch` link
253
+ to match the public layout: the declaration emit failed with the same TS2307
254
+ before this change, and tsup, the declaration emit and rollup dts all pass
255
+ after it.
256
+
257
+ ## 0.11.15
258
+
259
+ ### Build fix: the npm package publishes again (no API change)
260
+
261
+ **What changed.** 0.11.14 was published to GitHub Packages, PyPI and
262
+ crates.io, but the public npm publish of `@revturbine/sdk` failed at the bundle
263
+ step: `Could not resolve "openapi-fetch"` from `server-node/client.ts`, which
264
+ the `headless` entry re-exports. `openapi-fetch` has been bundled since BL-0354
265
+ (0.11.14 was the first release to carry it). A bare import resolves by walking
266
+ up from the importing file, so from `server-node/` it never reached
267
+ `web-sdk/node_modules`, which is the only place the public repo installs it.
268
+ `web-sdk/tsup.config.ts` now gives esbuild `web-sdk/node_modules` as a fallback
269
+ `nodePaths` entry, so every file the SDK bundles resolves the SDK's own
270
+ dependencies.
271
+
272
+ **npm users:** everything listed under 0.11.14 below first reaches npm in
273
+ 0.11.15. There is no behavior change between 0.11.14 and 0.11.15. PyPI and
274
+ crates.io get 0.11.15 in lockstep, with no change.
275
+
276
+ **Landed in:** 0.11.15.
277
+ **Fail-closed in:** n/a (build only).
278
+ **Proving test:** the public repo's `release-npm.yml` bundle step (tsup +
279
+ declarations) for `v0.11.15`. Locally, `web-sdk/tsup.config.ts` builds the
280
+ bundle, and an esbuild probe confirms `nodePaths` resolves a bare import from a
281
+ sibling directory that has no `node_modules` of its own.
282
+
283
+ ## 0.11.14
284
+
285
+ This release carries the BL-0335 change below plus every entry that main held
286
+ under "Unreleased" since 0.11.13 (BL-0381, BL-0382, BL-0366, BL-0375, BL-0369,
287
+ BL-0352, BL-0354 and BL-0351).
288
+
289
+ ### `tenantId` is optional on a browser init with a `publicKey`; the key is the sole arbiter of the tenant (BL-0335, D-49)
290
+
291
+ **What changed.** Before, a browser init needed both a `tenantId` and a
292
+ `publicKey`. The public key is bound to exactly one tenant, and the control
293
+ plane already resolved the tenant from the key alone, so the id was redundant
294
+ configuration, and a wrong one was silently ignored. Now:
295
+
296
+ - `RevTurbineInitOptions.tenantId` is **optional**. The minimal hosted init is
297
+ `{ publicKey, endpoint }`.
298
+ - A key-only integration sends **no** `x-tenant-id`. It reads the tenant from
299
+ the key-authenticated Playbook delivery: the signed manifest's `tenant_id`
300
+ and the Playbook's own `tenant_id`. Until the first Playbook loads, the
301
+ tenant is unknown, so `getUserContext().tenant_id` and the event envelope's
302
+ `tenant_id` read `''` and the `/api/track` row omits `tenant_id`. The
303
+ control plane stamps the key's tenant regardless.
304
+ - A **configured** `tenantId` that matches the key's tenant behaves exactly as
305
+ before: it is still sent, and every locally persisted key (anonymous id,
306
+ decision cache, caps, interaction state) is unchanged.
307
+ - A **configured** `tenantId` that does **not** match the key's tenant is no
308
+ longer silently ignored, and it is not refused either (D-49). When the
309
+ key-authenticated delivery names another tenant, the SDK logs one warning
310
+ per init and adopts the key's tenant:
311
+
312
+ ```
313
+ [RevTurbine] tenantId <configured> does not match the key's tenant <key tenant>; using <key tenant>
314
+ ```
315
+
316
+ The signed manifest is activated (before, a manifest whose tenant differed
317
+ from the configured one was refused and the SDK fell back to the legacy
318
+ config fetch). After adoption, `getUserContext().tenant_id`, event envelopes
319
+ and the `x-tenant-id` the SDK still sends all carry the key's tenant. Local
320
+ storage keys stay namespaced by the configured id, so nothing persisted is
321
+ lost.
322
+ - A key-only integration keys local state by a short hash of the public key
323
+ (`revturbine:pk-…:`), so rotating the key starts a fresh local namespace,
324
+ just as changing `tenantId` always did.
325
+ - The theme loader (`fetchThemeOverride`) no longer sends the `'local'`
326
+ placeholder as a tenant header. `ThemeLoaderOptions.tenantId` is optional,
327
+ and the new `storageScope` option keeps the persisted-theme key.
328
+ - `ConfigTargetDefaults.tenantId` is optional. Without it, the artifact's own
329
+ `tenant_id` is used.
330
+ - Init now fails loudly when no tenant can be identified. It throws when
331
+ neither `publicKey` nor `tenantId` is present, and when a `local_only` init
332
+ has no `tenantId` and no `localRuntime.playbook`. With a local Playbook,
333
+ `local_only` still defaults the tenant to `'local'`, and a local Playbook's
334
+ `tenant_id` stays a guard behind the init option (plan 233 REQ-2): there is
335
+ no key there to arbitrate.
336
+
337
+ **Server side (web #927).** The control plane uses the key's tenant
338
+ unconditionally on every public-key lane. A tenant id that IS sent, whether as
339
+ a header or as `tenant_id` in an event or interaction body, is diagnostic only:
340
+ a mismatch is logged server-side and ignored. No request is refused for it.
341
+
342
+ **Landed in:** 0.11.14 (`web-sdk` only; `server-python`/`server-rust` bumped
343
+ in lockstep with no behavior change: the server ports authenticate with the
344
+ server key and build a per-request context with a `tenant_id`, and they have
345
+ no browser `publicKey` init option to relax).
346
+ **Fail-closed in:** 0.11.14. An init with no identifiable tenant throws;
347
+ before, it was a type error only, and JS callers got `undefined`. A mismatching
348
+ configured tenant warns from 0.11.14; before, it was silent.
349
+ **Proving test:**
350
+ - `web-sdk/customer-side-tenant-optional.test.ts`: key-only init on both
351
+ delivery lanes (no tenant header; tenant adopted from the manifest and
352
+ Playbook), configured matching tenant unchanged (header sent, storage keys
353
+ byte-identical), a mismatching configured tenant warned once and the key's
354
+ tenant adopted on both lanes, the key-hash namespace, the init guards, and
355
+ the theme loader.
356
+ - `web-sdk/init-options-exactness.test-d.ts`: a key-only init compiles.
357
+
358
+ ### server-node `setBuiltinDimensions()` — your backend sets built-in dimension values, with an `override` flag (BL-0382, rulings D-46 / D-47)
359
+
360
+ **What changed.** Additive. The customer's backend can now SET built-in
361
+ segment dimension values for a user, not just read the control plane's
362
+ (D-46: "the customer's backend calling revturbine to set the dimension").
363
+
364
+ - **server-node** — `RevTurbineServer.setBuiltinDimensions(userId, dimensions,
365
+ { override })` calls the widened `upsertServerUserContext`
366
+ (`POST /api/sdk/user-contexts`) with `{ user_id, builtin_dimensions,
367
+ override }` and the server key. Settable: `subscription_state`,
368
+ `trial_type`, `billing_health`, `activity_level`, `buyer_role`,
369
+ `email_type`, `region`, `device_type` (typed as
370
+ `ServerBuiltinDimensionsWrite`, each a closed vocabulary; `null` clears a
371
+ dimension). `seat_type` stays `assignSeatType`. It resolves to the stored
372
+ state — `seat_type_handle`, the customer-set `builtin_dimensions`, the
373
+ pinned `overrides`, `updated_at`. A refusal throws the new
374
+ `RevTurbineDimensionWriteError` (`status`, `requestId`, machine `code`,
375
+ typed `reason`: `invalid_request` / `unauthorized` / `forbidden` /
376
+ `unknown_dimension_value` / `request_failed`); it never carries the key.
377
+ - **`override` (D-47).** `false` (default) is an UPDATE: the value is delivered
378
+ until a newer RevTurbine enrichment change supersedes it (last writer wins,
379
+ enrichment included), and writing a pinned dimension without the flag
380
+ unpins it. `true` is an OVERRIDE: the dimension is pinned and enrichment
381
+ never replaces it until you write it again without the flag or clear it.
382
+ The control plane applies this before delivery, so the browser SDK's client
383
+ context and `getBuiltinDimensions()` both return the result; the SDK
384
+ delivery rule and the parity goldens are unchanged.
385
+ - **`assignSeatType(userId, handle, { builtinDimensions, override })`** can
386
+ write dimension values in the same upsert. Its `SeatAssignmentErrorReason`
387
+ gains `unknown_dimension_value` (a 422 with code `UNKNOWN_DIMENSION_VALUE`).
388
+ Its response now also carries `builtin_dimensions` and `overrides`.
389
+ - **Python and Rust get no HTTP client** (unchanged: neither port has a
390
+ server-key transport). A backend on those stacks calls the endpoint itself.
391
+ - Browser input types are untouched: `builtin_dimensions` is never a browser
392
+ write path.
393
+
394
+ The `@revt-eng/*` pins move to `0.1.374`, which widens the operation
395
+ (scaffold #431).
396
+
397
+ **Landed in:** 0.11.14.
398
+ **Fail-closed in:** n/a (additive).
399
+ **Proving test:** `tests/server-node-set-builtin-dimensions.test.ts`,
400
+ `tests/server-node-assign-seat-type.test.ts`.
401
+
402
+ ### Behaviour change: the browser SDK ignores app-set `builtin_dimensions` (BL-0381, ruling D-46)
403
+
404
+ **What changed.** Trust in a built-in dimension value is decided by *party*
405
+ (Kent, D-46): the browser client never asserts one, the customer's server may,
406
+ and RevTurbine's own enrichment is optional. `builtin_dimensions` therefore
407
+ joins `seat_type_handle`, `activity_score` and `activity_score_computed_at`
408
+ as a server-assigned key in the browser SDK.
409
+
410
+ - **Types.** `builtin_dimensions` is omitted from `RevTurbineUserContext`,
411
+ `RevTurbineUpdateInput` and `IdentifyContextInput`, so
412
+ `update({ builtin_dimensions })`, `setUserContext({ builtin_dimensions })`,
413
+ `identify(id, { builtin_dimensions })` and `user: { builtin_dimensions }`
414
+ no longer compile.
415
+ - **Runtime.** Every browser entry point drops the key and warns with the
416
+ server-assigned diagnostic (`update()`, `identify()`, `setUserContext()`,
417
+ the `user` init option). A persisted `local_only` state blob that carries it
418
+ is purged on restore, and a server-action result that echoes it is stripped
419
+ silently. Before this change, 0.11.12 and 0.11.13 merged an app-set value
420
+ through `setUserContext()` and the `user` option. An app could then claim
421
+ any built-in segment (for example `rt.subscription_state.paid`) from the
422
+ browser.
423
+ - **The one browser source** is the authenticated
424
+ `GET /api/sdk/client-context` delivery (`fetchClientContext`, or a
425
+ `clientSession` minter). Its values overlay per leaf exactly as before.
426
+ `getUserContext()` still returns the held value.
427
+ - **`local_only`.** Built-in segment *definitions* are still synthesized for
428
+ a local Playbook (BL-0365), so chips and rules naming `rt.*` segments stay
429
+ valid. Their *values* no longer come from the app: **a browser `local_only`
430
+ app can no longer target built-ins unless a server-provided context
431
+ delivers them.** With no delivery every `rt_*` trait is absent and the
432
+ built-in segments fail closed (PD-4). `rt.registration_state.*` still
433
+ resolves from the identified id alone.
434
+ - **Unchanged: server SDKs.** They are the customer's server. Their overlay
435
+ inputs stay: server-node's `createStaticProviders({ userContext:
436
+ { builtin_dimensions }, serverBuiltinDimensions })`, Python's
437
+ `builtin_dimensions` / `server_builtin_dimensions` and Rust's
438
+ `builtin_dimensions` / `server_builtin_dimensions`. Parity goldens are
439
+ unchanged.
440
+
441
+ This supersedes the part of BL-0352 (below) that made `update()` apply
442
+ `builtin_dimensions`. That change never shipped.
443
+
444
+ **Landed in:** 0.11.14.
445
+ **Fail-closed in:** the same release. The compile-time omission and the
446
+ runtime drop ship together.
447
+ **Proving test:**
448
+ - `web-sdk/update-keys-exhaustiveness.test-d.ts` (`pnpm check:types:exact`):
449
+ `builtin_dimensions` is in the server-assigned set.
450
+ - `web-sdk/identify-guardrails.test.ts`: describe block
451
+ `builtin_dimensions never enters the browser context from the app`.
452
+ - `web-sdk/local-builtin-segments.test.ts`: `chip … with app-set … fails
453
+ closed` (and the delivered cases still match).
454
+ - `web-sdk/customer-side-rt-entitlement-gate.test.ts`: `denies when the app
455
+ sets builtin_dimensions itself via update()`.
456
+ - `web-sdk/customer-side-client-context.test.ts`: `holds exactly the
457
+ delivered value; an app-set value never reaches the context`.
458
+
459
+ ### Server SDKs get the control plane's built-in dimensions: `getBuiltinDimensions()` + a server overlay (BL-0366, plan 279 PD-3)
460
+
461
+ **What changed.** Additive. Before this, a server-side decision never saw the
462
+ control plane's Subscription State, Trial Type, Activity State, Seat Type or
463
+ Buyer Role. The Python and Rust ports stamped `rt_*` traits only from the
464
+ app-supplied `builtin_dimensions`, and the fetch that delivers the server's
465
+ values existed only in the browser SDK's client-context lane.
466
+
467
+ - **server-node** — `RevTurbineServer.getBuiltinDimensions(userId)` calls
468
+ `getServerUserBuiltinDimensions`
469
+ (`GET /api/sdk/user-contexts/{userId}/builtin-dimensions`) through the
470
+ generated typed client with the server key the client already holds. It
471
+ resolves to `{ tenant_id, user_id, builtin_dimensions, resolved_at }`
472
+ (re-exported as `ServerUserBuiltinDimensions`). The tenant comes from the
473
+ key: it is not a parameter, and no tenant header is sent. A refusal throws
474
+ the new `RevTurbineBuiltinDimensionsError` (`status`, `requestId`, machine
475
+ `code`, typed `reason`: `invalid_request` / `unauthorized` / `forbidden` /
476
+ `request_failed`). It never carries the key.
477
+ - **The overlay (all three ports).** `createStaticProviders` takes
478
+ `serverBuiltinDimensions` (from `@revt-eng/core`, re-exported by server-node
479
+ together with `applyServerBuiltinDimensions`). Python's
480
+ `create_static_providers(server_builtin_dimensions=…)` and the
481
+ `RevTurbineCustomerSdk` user context key `server_builtin_dimensions` take
482
+ the same object. So do Rust's `StaticProviderOptions::server_builtin_dimensions`
483
+ and `sdk::UserContext::server_builtin_dimensions`, plus the
484
+ `apply_server_builtin_dimensions` helper in both ports. The object is
485
+ overlaid per key on the app-set `builtin_dimensions` before segment
486
+ membership is evaluated. A delivered leaf wins over the app's value (PD-3),
487
+ and an app-set leaf the server did not deliver is kept. This is the overlay
488
+ the browser SDK already applies to a client-context delivery. Without a user
489
+ context it is ignored, because nothing is evaluated then.
490
+ - **Python and Rust get no HTTP client.** Neither port has a transport or a
491
+ server-key client (BL-0354), and this change does not add one. The app's
492
+ backend reads the endpoint itself (or through server-node) and passes the
493
+ response's `builtin_dimensions` in.
494
+
495
+ Rust callers that build `StaticProviderOptions` or `sdk::UserContext` as a
496
+ full struct literal without `..Default::default()` must add the new field.
497
+
498
+ The `@revt-eng/*` pins move to `0.1.373`, which adds the operation and the
499
+ core overlay (scaffold #430).
500
+
501
+ **Landed in:** 0.11.14.
502
+ **Fail-closed in:** n/a (additive).
503
+ **Proving test:** `tests/server-node-get-builtin-dimensions.test.ts`,
504
+ `server-python/tests/adapters/test_static.py`,
505
+ `server-rust/tests/static_providers.rs`, and the parity fixtures
506
+ `segment_membership_server_overlay_wins` / `segment_membership_server_overlay_demotes`.
507
+
508
+ ### `RevTurbineProvider` disposes the previous SDK instance on an `options` change (BL-0375)
509
+
510
+ **What changed.** `RevTurbineProvider`'s init effect creates a new SDK instance
511
+ whenever the `options` prop (or `bootstrapPlacements`) changes identity, but its
512
+ cleanup previously only flipped an internal `mounted` flag — it never called
513
+ `dispose()` on the instance it was replacing. A host that rebuilds `options` on
514
+ every render (an unmemoized `options={{ ... }}` literal, or an object built in
515
+ an un-annotated `useMemo` with an unstable dependency — the shape web's dogfood
516
+ provider had until web #944) therefore re-initialized on every render and
517
+ leaked every earlier instance's flush-interval timer, `pagehide` /
518
+ `visibilitychange` listeners, and any buffered telemetry.
519
+
520
+ The cleanup now calls the previous instance's existing `dispose()` (flushes
521
+ pending telemetry, detaches its listeners and timer) before the next render's
522
+ instance is constructed. The re-init contract is unchanged: a new `options`
523
+ object identity — even one with identical content — still creates a new SDK
524
+ instance; that documented behavior was never the bug, the leak was. When a new
525
+ `options` object is recreated with the same content as the previous one, the
526
+ provider now also logs a one-time (per mounted provider lifetime) development
527
+ warning pointing integrators at memoizing `options`, in addition to the
528
+ existing every-time identity-change warning.
529
+
530
+ **Landed in:** no release — no signature changed; `dispose()` on
531
+ `RevTurbineCustomerSdk` already existed (documented for manual SPA-unmount
532
+ teardown) and is now also called automatically by the provider.
533
+ **Fail-closed in:** n/a — purely a leak fix; no customer-visible API changed.
534
+ **Proving test:**
535
+ `web-sdk/react/RevTurbineProvider.dispose-on-options-change.test.tsx`.
536
+
537
+ ### Behaviour fix: server SDKs stop serving segment-chipped payloads to every user (BL-0369)
538
+
539
+ **What changed.** On the server lanes, every configured Playbook segment
540
+ counted as a segment the user was in. That covered the Python and Rust
541
+ `RevTurbineCustomerSdk`, `LocalRuntime`, and server-node's
542
+ `LocalEvaluationServer` fed by `createStaticProviders`. So a payload chipped
543
+ to any segment was shown to every user, and an entitlement rule targeted at
544
+ any segment granted to every user on the plan. This included the generated
545
+ built-in segments (`rt.<dimension>.<value>`, e.g.
546
+ `rt.subscription_state.paid`) that hosted Playbooks now export. The browser
547
+ SDK was not affected.
548
+
549
+ A user is now in a segment only when one of these is true, the same rule the
550
+ browser SDK applies:
551
+
552
+ - your app passed its handle in `segment_ids` (Python / Rust) or
553
+ `segmentIds` (`createStaticProviders`), or
554
+ - its predicates match the user's targeting traits. Those traits are built
555
+ from `custom`, the plan, and the reserved `rt_<dimension>` traits.
556
+ `rt_<dimension>` traits come **only** from `builtin_dimensions`, which the
557
+ server sets (plan 279 PD-3). For experiment segments, the user's
558
+ `experiments` assignments decide enrollment.
559
+
560
+ Anything else fails closed. An unset dimension, a trait the user doesn't
561
+ carry, or a chip that names an unknown segment serves nothing (PD-4).
562
+
563
+ New inputs (all additive): `custom`, `builtin_dimensions` and `experiments`
564
+ on the Python `UserContext` and the Rust `UserContext` struct. `segment_ids`
565
+ now also drives chips and rules, not just the catalog helpers.
566
+ `createStaticProviders` gets `segmentIds` and `userContext` from
567
+ `@revt-eng/core`. A side fix: the Python and Rust rules providers now carry
568
+ `segment_dimensions` and the Playbook's `format_version`, like TypeScript.
569
+ Without `segment_dimensions`, a rule targeted at segments in two dimensions
570
+ would have matched on either one once membership became real.
571
+
572
+ **Impact on existing integrations.** This is a correctness fix, but you may
573
+ notice it. If a server-side integration shows segment-chipped payloads or
574
+ grants segment-targeted entitlements, and it passes none of the inputs
575
+ above, those payloads now **stop showing** and those rules **stop
576
+ granting**. They were being over-served before. To keep serving users who
577
+ really are in the segment, pass `segment_ids` for segments your app
578
+ resolves, and `builtin_dimensions` / `custom` for predicate and built-in
579
+ segments. The Rust `UserContext` struct gains three fields. A struct literal
580
+ that does not end in `..Default::default()` needs them added.
581
+
582
+ The `@revt-eng/*` pins move to `0.1.372`: scaffold #429 is the BL-0369 core
583
+ fix, and it carries scaffold #428 (0.1.371: the `billing_health`
584
+ built-in dimension leaf, BL-0317, and `seat_type_handle` becoming
585
+ required-nullable on `ServerUserContextUpsert`, BL-0367). The vendored port
586
+ types follow: in Python, `ServerUserContextUpsert.seat_type_handle` loses its
587
+ default; in Rust, `ClientContextBuiltinDimensionsBillingHealth` is added.
588
+
589
+ **Landed in:** 0.11.14 (`@revt-eng/core` 0.1.372 carries
590
+ the scaffold BL-0369 fix).
591
+ **Fail-closed in:** the same release.
592
+ **Proving test:**
593
+ - `tests/parity/fixtures/segment_membership_{no_inputs,builtin_and_traits,explicit_ids}.json`
594
+ (TS == Python == Rust).
595
+ - `server-python/tests/adapters/test_static.py` and
596
+ `server-rust/tests/static_providers.rs` (segment membership tests).
597
+ - `tests/server-node-local-segment-membership.test.ts`.
598
+
599
+ ### `update()` carries `experiments` and `builtin_dimensions`; server-computed keys are dropped at every browser entry point (BL-0352, ruling D-36)
600
+
601
+ **What changed.** `update()`'s exhaustiveness check never ran. It was an
602
+ `expectTypeOf` inside a vitest `.test.ts`, and vitest does not typecheck. Four
603
+ fields had reached `RevTurbineUpdateInput` without reaching
604
+ `RECOGNIZED_UPDATE_KEYS`, so `update()` accepted them at compile time and
605
+ silently dropped them at runtime. Each one now has an explicit disposition:
606
+
607
+ - *(`builtin_dimensions` superseded by BL-0381 above: it is server-assigned
608
+ and dropped.)* `experiments` and `builtin_dimensions` are **now applied**
609
+ by `update()`.
610
+ The app may supply experiment assignments it already knows
611
+ (`runtime/experiment-assignment.md` §3, plan 183), and it may seed built-in
612
+ dimensions, for example in `local_only` mode. The client-context overlay
613
+ still wins when it is present (`targeting-studio-ui.md` §4.1). Before this
614
+ change, `update({ experiments })` compiled and did nothing.
615
+ - `activity_score` and `activity_score_computed_at` are **server-computed**.
616
+ The control plane's hourly activity-score refresh writes them (plan 180),
617
+ and the browser receives only the derived `activity_level` dimension. Like
618
+ `seat_type_handle` (BL-0351), they are now omitted from
619
+ `RevTurbineUserContext`, `RevTurbineUpdateInput` and `IdentifyContextInput`.
620
+ - `setUserContext()` and the `user` init option now **drop server-assigned
621
+ keys at runtime** (`seat_type_handle`, `activity_score`,
622
+ `activity_score_computed_at`) and report them. `identify()` and `update()`
623
+ already dropped them, and now report them with a server-assigned warning
624
+ that names the backend as the writer, not the "pass it under `custom`"
625
+ hint. Before this change, a plain-JS `setUserContext({ seat_type_handle })`
626
+ merged the key into the held context, and local mode persisted it. A state
627
+ blob persisted before this change is purged of those keys when it is
628
+ restored. A server-action result that echoes them back is stripped without
629
+ a warning.
630
+ - New exported types `RecognizedUpdateKey` and `RecognizedIdentifyKey`
631
+ (additive) back a real compile-time derivation in
632
+ `web-sdk/update-keys-exhaustiveness.test-d.ts`, which
633
+ `pnpm check:types:exact` compiles. A future `UserContext` field fails the
634
+ build until it is added to the runtime list or omitted as server-assigned.
635
+
636
+ **Deferred to `0.12.0`:** `setUserContext()` still takes
637
+ `RevTurbineUserContext` without `Exact<>`, so other undeclared keys that
638
+ arrive through a variable are not rejected at compile time. Adding `Exact<>`
639
+ would break callers that pass a wider object today. `IdentifyContextInput`
640
+ also still declares 11 keys that `identify()` drops with a warning (`trial`,
641
+ `tiers`, `experiments`, …). The type-test pins that list, so it cannot grow
642
+ silently. Narrowing the type is a breaking change and waits for the minor.
643
+
644
+ The `@revt-eng/*` pins move to `0.1.370` (scaffold #427: discovery-owned
645
+ `SurfaceSlotSchema.slot_name`). That field now appears in the generated client
646
+ types and the vendored Python and Rust port types. The SDK does not write it.
647
+
648
+ **Landed in:** 0.11.14.
649
+ **Fail-closed in:** the same release. The compile-time omission and the
650
+ runtime drop at all four entry points both ship in it.
651
+ **Proving test:**
652
+ - `web-sdk/update-keys-exhaustiveness.test-d.ts` (`pnpm check:types:exact`).
653
+ - `web-sdk/identify-guardrails.test.ts`: describe blocks
654
+ `server-computed keys never enter the browser context`,
655
+ `setUserContext() drops server-assigned keys at its runtime boundary` and
656
+ `update() carries app-owned experiments` (renamed by BL-0381).
657
+
658
+ ### server-node `assignSeatType()` writes a user's seat type (BL-0354, plan 279 TASK-16a, ruling D-36)
659
+
660
+ **What changed.** Additive. `RevTurbineServer` (server-node) gains
661
+ `assignSeatType(userId: string, seatTypeHandle: string | null)`. It calls
662
+ `upsertServerUserContext` (`POST /api/sdk/user-contexts`) through the generated
663
+ typed client with the server key the client already holds. The request body is
664
+ exactly `{ user_id, seat_type_handle }`. `null` clears the assignment. The
665
+ tenant comes from the key: it is not a parameter, and no tenant header is sent.
666
+ It resolves to the stored assignment
667
+ `{ tenant_id, user_id, updated_at, seat_type_handle }` (re-exported as
668
+ `ServerUserContextAssignment`).
669
+
670
+ A refusal throws the new `RevTurbineSeatAssignmentError`. It carries `status`,
671
+ `requestId`, the control plane's machine `code` when present, and a typed
672
+ `reason`: `unknown_seat_type` (422: the handle is not one of the tenant's seat
673
+ types), `forbidden` (403: the credential is not a server key), `unauthorized`
674
+ (401), `invalid_request` (400) or `request_failed`. Like
675
+ `RevTurbineClientSessionError`, it never carries the key or the free-text
676
+ response message.
677
+
678
+ Per D-36 the seat assignment is server-written only, so there is no browser SDK
679
+ surface for this. The Python and Rust ports have no HTTP client or server-key
680
+ client, only the vendored `ServerUserContextUpsert` / `ServerUserContextAssignment`
681
+ models, so they gain no helper.
682
+
683
+ The `@revt-eng/*` pins move to `0.1.369`, which adds that operation to the
684
+ external contract (scaffold #425).
685
+
686
+ **Landed in:** 0.11.14.
687
+ **Fail-closed in:** n/a (additive).
688
+ **Proving test:** `tests/server-node-assign-seat-type.test.ts`.
689
+
690
+ ### `seat_type_handle` is server-assigned and never a browser input (BL-0351, plan 279 TASK-16a, ruling D-36)
691
+
692
+ **What changed.** Scaffold `0.1.368` adds `UserContextSchema.seat_type_handle`,
693
+ the user's persisted seat-type assignment. Per D-36 it has exactly two writers:
694
+ the tenant's backend (a server-key user-context upsert) and RevTurbine's own
695
+ server-side enrichment in hosted mode. The browser never writes it. Without a
696
+ change here the new field would have flowed into the browser input types
697
+ through `UserContextInput`. Instead:
698
+
699
+ - `RevTurbineUserContext` omits `seat_type_handle`. So do
700
+ `RevTurbineUpdateInput`, which derives from it, and `IdentifyContextInput`.
701
+ `identify()`, `update()` and the `user` init option reject the key at compile
702
+ time, including when it arrives through a variable (`Exact<…>`).
703
+ `setUserContext()` rejects it in an object literal.
704
+ - The key stays out of the runtime recognized-key lists, so a plain-JS caller's
705
+ `seat_type_handle` is dropped at the `identify()`/`update()` boundary and
706
+ reported by the existing unrecognized-key warning. It is never merged into the
707
+ held context.
708
+ - The browser receives the **resolved** seat type as
709
+ `builtin_dimensions.seat_type` / `builtin_dimensions.buyer_role` from the
710
+ client-context fetch (plan 279 TASK-16, web), unchanged here.
711
+
712
+ No released SDK type ever carried `seat_type_handle`, so nothing an
713
+ integration compiles today stops compiling. The `@revt-eng/*` pins move to
714
+ `0.1.368`. That version also adds `SeatTypeSchema.is_buyer` (default `false`),
715
+ which now appears in the vendored Python and Rust port types.
716
+
717
+ A server-node `assignSeatType(userId, handle | null)` helper was **not** in this
718
+ change, because the server-key operation it wraps (`upsertServerUserContext`,
719
+ `POST /api/sdk/user-contexts`, plan 279 TASK-15a) was not yet in the published
720
+ external contract. It shipped with scaffold `0.1.369` (see the BL-0354 entry
721
+ above).
722
+
723
+ **Landed in:** 0.11.14. This PR bumps pins only.
724
+ **Fail-closed in:** the same release (compile-time rejection; runtime drop).
725
+ **Proving test:**
726
+ - `web-sdk/user-context-exactness.test-d.ts`: the `@ts-expect-error` block
727
+ "Server-assigned fields" (`pnpm check:types:exact`).
728
+ - `web-sdk/identify-guardrails.test.ts`: describe block
729
+ `seat_type_handle never enters the browser context`.
730
+
50
731
  ## 0.11.13
51
732
 
52
733
  ### Slot lifecycle events carry the slot's `route` (BL-0207, ruling D-30)