@revturbine/sdk 0.11.4 → 0.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -47,6 +47,137 @@ also require a changelog entry.
47
47
 
48
48
  ---
49
49
 
50
+ ## 0.11.6
51
+
52
+ ### `sdk.convert()` reloads the UserContext, so a mounted slot re-decides (BL-0004)
53
+
54
+ **What changed.** `sdk.convert(outputId)` used to record the conversion and stop
55
+ there. Conversion writes **no** cooldown and **no** permanent retirement — by
56
+ design (Kent's plan 254 D-9 ruling; the `convert` row of the spec's interaction
57
+ table) — so eligibility is a function of the user's CURRENT plan and targeting.
58
+ That left a real gap: a host that called the public convert path while the
59
+ placement was mounted saw the upgrade banner stay on screen until the slot next
60
+ happened to resolve a decision. The documented workaround was a manual
61
+ `refresh()` or the component's `ctaComplete()`. Plan 236 TASK-13 recorded it as
62
+ an open product call rather than asserting it away.
63
+
64
+ Kent ruled on it, 2026-09-25: *"Converting should trigger a reloading of
65
+ UserContext with the new plan/billing state the user has converted to."*
66
+
67
+ So `convert()` now, after recording the interaction:
68
+
69
+ 1. **Optimistically** moves the held plan when the converted output's CTA names
70
+ one (`cta_path.plan_handle` — e.g. `open_checkout` with `config.purchase`).
71
+ The Playbook supplies the plan's display name when it knows it.
72
+ 2. Notifies user-context subscribers **either way** — a CTA that names no plan
73
+ still re-decides mounted surfaces.
74
+ 3. **Then** refreshes from `GET /api/sdk/client-context`, which overlays the
75
+ RevTurbine-authoritative trial / billing-health / plan fields. Best-effort and
76
+ never throwing, exactly as before; a no-op when no client-session minter is
77
+ configured.
78
+
79
+ Optimistic-first because billing truth arrives by Stripe webhook: a
80
+ `client-context` read that races the webhook still reports the OLD plan, and
81
+ waiting for it would leave the upgrade prompt up at the exact moment the user
82
+ paid. For the same reason, while a conversion is pending a server plan **equal to
83
+ the plan converted away from** is dropped (plan fields only — trial and
84
+ billing-health always apply). Any other value clears the pending state and
85
+ applies, so the suppression cannot outlive the lag it covers.
86
+
87
+ Mounted `PlacementController`s re-decide and mounted `EntitlementGate`s re-check
88
+ through `watchUserContext()`. The React bindings attach it for you
89
+ (`usePlacement`, and so every surface slot built on it; `useEntitlement` /
90
+ `useCan` already did), and `SdkSession.placement()` / `.entitlement()` attach it
91
+ for headless consumers. `dispose()` drops it. `PlacementController.ctaComplete()`
92
+ is unchanged.
93
+
94
+ **What did NOT change.** `convert()`'s signature. Conversion still writes no
95
+ suppression and no retirement: back on the old plan, the placement is eligible
96
+ again. The optimistic plan move is a session-local overlay, not a new source of
97
+ plan truth — a host that re-declares `plan_handle` on mount wins, and that is the
98
+ intended precedence.
99
+
100
+ **Landed in.** `0.11.6` (BL-0004; refs BL-0177, BL-0179 for the re-decide
101
+ mechanism this reuses).
102
+
103
+ **Fail-closed in.** `0.11.6` — same version. Nothing was silently tolerated
104
+ before: the old behaviour was a placement that stayed visible, which is why the
105
+ limitation was documented rather than dated.
106
+
107
+ **Proving test.** `web-sdk/convert-reloads-user-context.test.ts` (headless: the
108
+ mounted controller's decision goes invisible, a failed conversion moves nothing,
109
+ a CTA with no plan still notifies, and the stale-server-plan guard both
110
+ suppresses and releases); `web-sdk/react/convert-reloads-user-context.test.tsx`
111
+ (a mounted `usePlacement` banner disappears in place and a mounted `useCan` gate
112
+ flips denied → granted, with no remount); `e2e/journey.spec.ts` BL-0004 legs (a
113
+ real browser: the converted placement leaves the screen with **no** page
114
+ refresh, and the optimistic overlay does not outrank the host's declaration).
115
+
116
+ **Ports.** No change. The Python and Rust SDKs are synchronous-with-config and
117
+ have no mounted surfaces to notify; they expose no `convert()` and no
118
+ UserContext-reload API, so there is no port-side behaviour to match. The
119
+ decision-level contract they do share — a context carrying the new plan yields
120
+ decisions for that plan — is unchanged, so no parity fixture moves.
121
+
122
+ ---
123
+ ## 0.11.5
124
+
125
+ ### `registerSurfaceSlot()` no longer writes to the control plane (BL-0197)
126
+
127
+ **What changed.** Outside `local_only`, `registerSurfaceSlot()` used to mirror
128
+ every slot to the server: `upsertSurfaceSlot()` PUT a legacy surface-slot body
129
+ (`{name, slug, slot_type, status, targeting_rules, content, priority,
130
+ metadata}`) to `/api/placements/<slot id>`, then POSTed it to `/api/placements`
131
+ on the 404. `/api/placements` is RevTurbine's **authored-config** CRUD; it
132
+ validates against `PlacementSchema`, which requires `handle` and `category`, so
133
+ that body could only ever be rejected. In production every non-`local_only`
134
+ slot registration failed with `surface_slot_create_failed:422` and the React
135
+ runtime turned the rejection into a `slot_error`
136
+ (`dogfood_event_explorer`, 2026-09-24 18:36 UTC, request
137
+ `030ccf51-d74b-4d55-bf4e-2a929a4ab297`).
138
+
139
+ The write was also wrong in principle. On a same-origin integration the
140
+ signed-in user's session cookie authenticates the request, so a body the route
141
+ *did* accept would have created a draft placement in the tenant's Playbook just
142
+ from loading a gated page. Only the 422 prevented that.
143
+
144
+ `registerSurfaceSlot()` is now **client-local**: it records the slot in the
145
+ in-process registry and resolves, with no network write of any kind. A write
146
+ happens only when you configure `endpointOverrides.surfaceSlots` (or
147
+ `custom_endpoints.surfaceSlots`) to point at a slot-inventory service you run —
148
+ the same shape `persistPlacementTypes()` already uses. There is **no fallback
149
+ to `/api/placements`** under any configuration.
150
+
151
+ Nothing is lost by removing it. Surface-slot discovery is **ingestion-driven**:
152
+ the SDK already emits `slot_evaluated` / `slot_filled` / `slot_empty` /
153
+ `slot_suppressed` / `slot_error` and the `placement_*` lifecycle through
154
+ `/api/track`, carrying `surface_slot_id`, `slot_name`, `template_ids`,
155
+ `surface_type`, `category`, `decision_source` and `reason_codes`, and the
156
+ pipeline derives discovered slots from that telemetry. `SurfaceSlotSchema` is a
157
+ runtime-source (DISCOVERED) schema with `first_seen` / `last_seen` — it was
158
+ never meant to be written by a browser.
159
+
160
+ **Landed in.** `0.11.5` (BL-0197).
161
+
162
+ **Fail-closed in.** `0.11.5` — same version. The removed path had no silent
163
+ mode: before this release it threw `surface_slot_create_failed:<status>` on
164
+ every default-transport registration, so there is no window where the old
165
+ behaviour was quietly tolerated. An integration that genuinely relied on the
166
+ write must set `endpointOverrides.surfaceSlots`; one that did not was already
167
+ seeing `slot_error`.
168
+
169
+ **Proving test.** `web-sdk/surface-slot-registration-no-write.test.ts` — the
170
+ default transport performs zero writes (fetch spy, no `/api/placements` call)
171
+ and resolves even when the route replays the production 404/422; the override
172
+ path still PUTs, and falls back to POST on the override base, never on
173
+ `/api/placements`. `e2e/journey.transport.spec.ts` no longer stubs
174
+ `/api/placements` with a 200 (the stub that hid this): it records and aborts,
175
+ and `assertNoPlacementWrites()` fails the test if anything calls it.
176
+
177
+ **Ports.** No change — the Python and Rust SDKs never had a slot-registration
178
+ write.
179
+
180
+ ---
50
181
  ## 0.11.4
51
182
 
52
183
  ### The treatment-interaction wire record now carries `rule_handle` (BL-0200)
@@ -17171,11 +17171,14 @@ interface RevTurbineTreatmentInteractionInput {
17171
17171
  * `PlacementOutput.rule_id` — projected onto the `placement_interaction`
17172
17172
  * clickstream payload as `rule_handle` (BL-0182).
17173
17173
  *
17174
- * Unlike its neighbours this does NOT reach the wire
17175
- * `TreatmentInteractionInputSchema` / `placement_presentations`: that path is
17176
- * column-shaped and would need a datasource migration. The clickstream
17177
- * carries payload fields inside a JSON column, so the rule slice for
17178
- * interactions is readable the moment producers send it.
17174
+ * It reaches the wire `TreatmentInteractionInputSchema` too, as of BL-0200.
17175
+ * It deliberately did not under BL-0182: that path is column-shaped and the
17176
+ * rule would have needed a datasource migration, while the clickstream
17177
+ * carries payload fields inside a JSON column and was readable at once.
17178
+ * `placement_exposure_attribution.rule_handle` exists now (web ledger 015),
17179
+ * so the base exposure row records the rule the exposure was DECIDED by
17180
+ * rather than waiting for attribution to reconstruct one when a conversion
17181
+ * lands — which is what makes presentation-grain rule cuts servable.
17179
17182
  *
17180
17183
  * Omitted, never sent empty, when no decision is in scope — a bare
17181
17184
  * `trackTreatmentInteraction` caller supplies no rule, and absence is what
@@ -20481,6 +20484,16 @@ interface RevTurbineEndpointOverrides {
20481
20484
  */
20482
20485
  legacyInteractions?: string;
20483
20486
  placementTypes: string;
20487
+ /**
20488
+ * Optional sink for registered surface slots.
20489
+ *
20490
+ * RevTurbine hosts **no default route** for this verb (BL-0197). Slot
20491
+ * registration is client-local and discovery is ingestion-driven — the
20492
+ * pipeline derives discovered slots from the `slot_*` telemetry the SDK
20493
+ * already emits through `/api/track`. Set this only when you run your own
20494
+ * slot-inventory service; unset, `registerSurfaceSlot()` performs no
20495
+ * network write.
20496
+ */
20484
20497
  surfaceSlots: string;
20485
20498
  }
20486
20499
  /** Client-side clickstream batching policy (plan 95 TASK-6). */
@@ -21137,6 +21150,12 @@ declare class RevTurbineCustomerSdk {
21137
21150
  * redundant segment re-evaluation.
21138
21151
  */
21139
21152
  private lastServerContextHash?;
21153
+ /**
21154
+ * The plan move an optimistic `convert()` made ahead of the server (BL-0004),
21155
+ * held only until a `client-context` read stops reporting the old plan. See
21156
+ * {@link dropStalePostConversionPlan}.
21157
+ */
21158
+ private pendingConversionPlan?;
21140
21159
  private usageBalances;
21141
21160
  private localDecisionsByPlacementId;
21142
21161
  private localPlacementsByLookupKey;
@@ -21934,6 +21953,30 @@ declare class RevTurbineCustomerSdk {
21934
21953
  * @public
21935
21954
  */
21936
21955
  registerPlacement(config: RevTurbinePlacementConfig): Promise<string>;
21956
+ /**
21957
+ * Mirror a registered surface slot to an explicitly configured backend.
21958
+ *
21959
+ * **Requires an explicit `surfaceSlots` endpoint override** (BL-0197).
21960
+ * There is no default route for this verb and there never was a correct
21961
+ * one: the previous default pointed at `/api/placements`, RevTurbine's
21962
+ * *authored-config* CRUD. That route validates against `PlacementSchema`
21963
+ * (which requires `handle` and `category`), so the legacy surface-slot body
21964
+ * this method sends could only ever be rejected — in production every
21965
+ * non-`local_only` registration failed with
21966
+ * `surface_slot_create_failed:422` and the React runtime turned that into a
21967
+ * `slot_error`.
21968
+ *
21969
+ * It was also wrong in principle. On a same-origin integration the signed-in
21970
+ * user's session cookie authenticates the request, so a body the route *did*
21971
+ * accept would have let merely loading a gated page create a draft placement
21972
+ * in the tenant's Playbook. Only the 422 prevented that.
21973
+ *
21974
+ * Surface-slot **discovery is ingestion-driven**: the SDK emits its
21975
+ * `slot_*` / `placement_*` telemetry through `/api/track` and the pipeline
21976
+ * derives discovered slots from it. Registration itself is client-local.
21977
+ *
21978
+ * @internal
21979
+ */
21937
21980
  private upsertSurfaceSlot;
21938
21981
  /**
21939
21982
  * Build the internal placement record for a surface-slot config.
@@ -21956,6 +21999,18 @@ declare class RevTurbineCustomerSdk {
21956
21999
  * @internal
21957
22000
  */
21958
22001
  private commitSurfaceSlotRecord;
22002
+ /**
22003
+ * Register a surface slot with the local runtime and return its placement id.
22004
+ *
22005
+ * **Client-local by default** (BL-0197): this performs no network write. The
22006
+ * slot is recorded in the in-process registry, and the pipeline discovers it
22007
+ * from the `slot_*` / `placement_*` telemetry the SDK emits through
22008
+ * `/api/track`. A write happens only when `custom_endpoints.surfaceSlots`
22009
+ * points at a slot-inventory service you run; there is no fallback to
22010
+ * `/api/placements`.
22011
+ *
22012
+ * @public
22013
+ */
21959
22014
  registerSurfaceSlot(config: RevTurbineSurfaceSlotConfig): Promise<string>;
21960
22015
  /**
21961
22016
  * Persist authored placement types to a configured backend.
@@ -22245,12 +22300,16 @@ declare class RevTurbineCustomerSdk {
22245
22300
  /** Map the client-safe context response into a UserContext patch (plan 157). */
22246
22301
  private mapClientSafeContext;
22247
22302
  /**
22248
- * Apply a server-derived UserContext patch (plan 157) — mirrors setUserContext
22249
- * but accepts a partial. The patch's RevTurbine-authoritative fields (trial,
22250
- * billing signals) overlay the held context; undefined fields are skipped by
22251
- * the merge, so app-set values are never nulled.
22303
+ * Apply a UserContext patch — mirrors setUserContext but accepts a partial.
22304
+ * RevTurbine-authoritative fields (trial, billing signals, server plan)
22305
+ * overlay the held context; undefined fields are skipped by the merge, so
22306
+ * app-set values are never nulled.
22307
+ *
22308
+ * Two callers: the server client-context fetch (plan 157) and the optimistic
22309
+ * post-conversion plan move (BL-0004). Both notify subscribers, which is what
22310
+ * makes mounted slots and gates re-resolve.
22252
22311
  */
22253
- private applyServerContextPatch;
22312
+ private applyUserContextPatch;
22254
22313
  /**
22255
22314
  * Record that a placement was rendered to the user. Plan 43 TASK-9.
22256
22315
  *
@@ -22427,13 +22486,73 @@ declare class RevTurbineCustomerSdk {
22427
22486
  */
22428
22487
  snooze(outputId: string, seconds?: number): Promise<void>;
22429
22488
  /**
22430
- * Record a confirmed CTA completion and retire the decision's placement
22431
- * permanently — future calls to `getPlacementDecision` for it will not
22432
- * return it again.
22489
+ * Record a confirmed CTA completion, then reload the UserContext so every
22490
+ * mounted surface re-decides against the plan the user just converted to.
22491
+ *
22492
+ * Conversion is **not** a suppression: it writes no cooldown and no permanent
22493
+ * retirement (spec §"interaction" table; Kent's plan 254 D-9 ruling). What
22494
+ * makes a converted upgrade banner leave the screen is the user no longer
22495
+ * matching it — so a conversion that did not refresh the context left the
22496
+ * placement mounted until something else happened to re-resolve it. Kent,
22497
+ * 2026-09-25 (BL-0004): *"Converting should trigger a reloading of UserContext
22498
+ * with the new plan/billing state the user has converted to."*
22499
+ *
22500
+ * Ordering, and why it is this way round:
22501
+ *
22502
+ * 1. The interaction is recorded (analytics + attribution), as before.
22503
+ * 2. **Optimistically**, locally: when the converted output's CTA names a
22504
+ * target plan (`cta_path.plan_handle`), the held context moves to that plan
22505
+ * immediately. Billing truth arrives by Stripe webhook, so
22506
+ * `GET /api/sdk/client-context` can still report the OLD plan for seconds
22507
+ * after a successful checkout — waiting for it would leave the upgrade
22508
+ * prompt on screen exactly when the user has just paid.
22509
+ * 3. Subscribers are notified either way — a conversion whose CTA names no
22510
+ * plan still re-decides mounted slots and re-checks mounted gates, which is
22511
+ * what turns a plan change the host applied itself into a re-render.
22512
+ * 4. **Then** the server refresh (`fetchClientContext()`), which overlays the
22513
+ * RevTurbine-authoritative trial / billing-health / plan fields and
22514
+ * notifies again if it changed anything. It is best-effort and never
22515
+ * throws, so awaiting it cannot fail a conversion; with no client-session
22516
+ * minter configured it is a no-op and the optimistic state stands.
22517
+ *
22518
+ * Mounted `PlacementController`s re-decide and mounted `EntitlementGate`s
22519
+ * re-check through their `watchUserContext()` subscriptions — which the React
22520
+ * bindings attach for you. Re-resolution emits the normal lifecycle events
22521
+ * once per final decision; the transient states emit nothing.
22522
+ * `PlacementController.ctaComplete()` is unchanged and remains the
22523
+ * component-level path.
22433
22524
  *
22434
22525
  * @public
22435
22526
  */
22436
22527
  convert(outputId: string): Promise<void>;
22528
+ /**
22529
+ * The BL-0004 post-conversion context reload: optimistic local plan move,
22530
+ * then the server refresh. See {@link convert} for the ordering rationale.
22531
+ */
22532
+ private reloadUserContextAfterConversion;
22533
+ /**
22534
+ * Keep a stale server plan from undoing an optimistic conversion (BL-0004).
22535
+ *
22536
+ * Billing truth arrives by Stripe webhook, so a `client-context` read that
22537
+ * races the webhook reports the PRE-conversion plan — and the server plan
22538
+ * deliberately overlays the app-supplied one (plan 179 TASK-1), so applying
22539
+ * that read verbatim would put the upgrade prompt straight back on screen
22540
+ * seconds after the user paid.
22541
+ *
22542
+ * So while a conversion is pending, a server plan equal to the plan converted
22543
+ * AWAY from is dropped — only the plan fields, never the trial or billing-health
22544
+ * signals, which are authoritative regardless. Any other value (the new plan,
22545
+ * or a third one the user was moved to out-of-band) clears the pending state
22546
+ * and applies, so this can suppress at most the one value it was created for
22547
+ * and cannot outlive the lag it exists to cover.
22548
+ */
22549
+ private dropStalePostConversionPlan;
22550
+ /**
22551
+ * The display name the loaded Playbook gives a plan handle, else the handle.
22552
+ * Keeps an optimistic plan move from putting a raw handle in front of a user
22553
+ * when the Playbook already knows the plan's name.
22554
+ */
22555
+ private planNameForHandle;
22437
22556
  /**
22438
22557
  * Emit a canonical trigger event. There is no in-SDK consumer that matches
22439
22558
  * on trigger names — the shipped behavior is ingest to `events_clickstream`
@@ -22897,12 +23016,41 @@ declare class PlacementController {
22897
23016
  private _awaitingConfig;
22898
23017
  private _configRetries;
22899
23018
  private _playbookUnsubscribe;
23019
+ private _userContextUnsubscribe;
22900
23020
  constructor(sdk: RevTurbineCustomerSdk, options: PlacementControllerOptions);
22901
23021
  /**
22902
- * Stop listening for Playbook loads. Idempotent; call it when the owning
22903
- * component unmounts (`usePlacement` does).
23022
+ * Stop listening for Playbook loads and user-context changes. Idempotent; call
23023
+ * it when the owning component unmounts (`usePlacement` does).
22904
23024
  */
22905
23025
  dispose(): void;
23026
+ /**
23027
+ * Drop only the Playbook-load subscription — the within-load-cycle teardown
23028
+ * (BL-0177). A mounted controller keeps watching the user context (BL-0004).
23029
+ */
23030
+ private unsubscribePlaybook;
23031
+ /**
23032
+ * Re-decide whenever the user context changes (BL-0004).
23033
+ *
23034
+ * The counterpart to {@link EntitlementGate.watchUserContext}, and the reason
23035
+ * `sdk.convert()` now removes a converted placement in place: conversion writes
23036
+ * no suppression — eligibility is a function of the user's CURRENT plan and
23037
+ * targeting — so the only thing that can take a converted upgrade banner off
23038
+ * screen is a re-decide against the reloaded context. Without this the
23039
+ * placement stayed mounted until something else happened to re-resolve it, and
23040
+ * the host had to call `refresh()` itself or use the component's
23041
+ * `ctaComplete()` (the limitation plan 236 TASK-13 recorded).
23042
+ *
23043
+ * Only re-decides a controller that has already resolved something: before the
23044
+ * first `load()` there is nothing on screen to be stale, and firing then would
23045
+ * turn every startup `identify()` into a duplicate decision.
23046
+ *
23047
+ * Calls `load()` rather than `refresh()` on purpose — `refresh()` clears
23048
+ * impression/exposure tracking, so a context change would re-credit a
23049
+ * presentation the user never saw again.
23050
+ *
23051
+ * Returns an unsubscribe function; {@link dispose} also drops it.
23052
+ */
23053
+ watchUserContext(): () => void;
22906
23054
  /**
22907
23055
  * Is this decision a race against a Playbook load rather than a verdict?
22908
23056
  *
@@ -23120,12 +23268,18 @@ declare class EntitlementGate {
23120
23268
  private _awaitedResult;
23121
23269
  private _configRetries;
23122
23270
  private _playbookUnsubscribe;
23271
+ private _userContextUnsubscribe;
23123
23272
  constructor(sdk: RevTurbineCustomerSdk, options: EntitlementGateOptions);
23124
23273
  /**
23125
- * Stop listening for Playbook loads. Idempotent; call it when the owning
23126
- * component unmounts (`useEntitlement` does).
23274
+ * Stop listening for Playbook loads and user-context changes. Idempotent; call
23275
+ * it when the owning component unmounts (`useEntitlement` does).
23127
23276
  */
23128
23277
  dispose(): void;
23278
+ /**
23279
+ * Drop only the Playbook-load subscription — the within-check-cycle teardown
23280
+ * (BL-0179). A mounted gate keeps watching the user context (BL-0004).
23281
+ */
23282
+ private unsubscribePlaybook;
23129
23283
  /** Current state snapshot. */
23130
23284
  get state(): EntitlementGateState;
23131
23285
  /** Convenience: `true` when the entitlement is allowed. */
@@ -23225,8 +23379,13 @@ declare class EntitlementGate {
23225
23379
  * `check()` there is nothing on screen to be stale, and firing then would
23226
23380
  * turn every `identify()` at startup into a redundant evaluation.
23227
23381
  *
23382
+ * `sdk.convert()` reloads the UserContext (BL-0004), so this is also what
23383
+ * flips a mounted `useCan` gate to allowed after a conversion.
23384
+ *
23228
23385
  * Returns an unsubscribe function. Call it when the gate is discarded —
23229
23386
  * without that, a gate outlives its consumer and keeps re-checking.
23387
+ * Idempotent: a second call returns the first subscription rather than
23388
+ * orphaning it, and {@link dispose} drops it too.
23230
23389
  */
23231
23390
  watchUserContext(): () => void;
23232
23391
  private notify;