@revturbine/sdk 0.11.1 → 0.11.3

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,139 @@ also require a changelog entry.
47
47
 
48
48
  ---
49
49
 
50
+ ## 0.11.3
51
+
52
+ ### `placement_interaction` now carries `rule_handle` (BL-0182)
53
+
54
+ **What changed.** #527 (BL-0062) stamped `rule_handle` at every verdict emit
55
+ site by reading `decision.output.rule_id`, but `placement_interaction` is not
56
+ a lifecycle event and does not spread `placementLifecycleBase` — its
57
+ placement keys are all optional, because a bare `trackTreatmentInteraction`
58
+ caller may supply none of them — so the click between exposure and outcome
59
+ was the one step of the funnel with no rule key. That is the step
60
+ click-through and CTR are computed over: the funnel was sliceable by rule at
61
+ both ends and not in the middle.
62
+
63
+ Five emit sites now reach the same stamped payload: `trackTreatmentInteraction`
64
+ (the projection itself), `PlacementController.trackInteraction`
65
+ (dismiss / remind_me_later / cta_clicked / cta_completed — and therefore every
66
+ React hook and `useSurfaceSlot` / `FixedSurfaceSlot` click handler built on
67
+ it), `PlacementController.fireImpression`, `trackOutputInteraction`
68
+ (output-addressed `convert()` / `dismiss()` / `snooze()`), and the
69
+ server-action `trackResult` resolver.
70
+
71
+ Spread, not set: `rule_handle` is **absent** when no decision was in scope
72
+ (a bare `trackTreatmentInteraction` call with no placement context), and
73
+ `null` on the wire means "a rule was selected and none matched" — conflating
74
+ the two would poison the slice's coverage numbers. The unknown-output branch
75
+ of `trackOutputInteraction` stamps nothing on purpose: no decision from this
76
+ SDK produced that output, so there is no rule to name.
77
+
78
+ Companion to `revturbine-scaffold#391`, which added `rule_handle` to the
79
+ `placement_interaction` payload contract and `ruleHandle` to
80
+ `RevTurbineTreatmentInteractionInput` (published as `@revt-eng/*` v0.1.339,
81
+ pinned in this release).
82
+
83
+ **No wire-shape change beyond the new optional field.** `rule_handle` is
84
+ optional-and-nullable on the schema side (#391), which keeps every
85
+ already-deployed SDK out of `quarantineVerdict`. `@public` signatures are
86
+ unchanged (`web-sdk/generated/public-api.json` untouched) and
87
+ `flushInteractionQueue` still maps the treatment-interaction record field by
88
+ field, so `ruleHandle` never reaches a customer webhook.
89
+
90
+ **Python and Rust are unaffected.** Neither port emits events.
91
+
92
+ **Landed in** `0.11.3` (BL-0182, #530).
93
+
94
+ **Fail-closed in** not applicable — additive field, nothing old is rejected.
95
+
96
+ **Proving test:** `web-sdk/customer-side-canonical-interaction.test.ts`.
97
+
98
+ ---
99
+
100
+ ### A gate no longer paywalls forever when it loses the cold-start config race (BL-0179)
101
+
102
+ **What changed.** BL-0177 (below) split `config_unavailable` into a transient and
103
+ a terminal state for **placements** and left `checkEntitlement` on the old
104
+ behaviour. So the same cold-start race denied entitlements instead: while the
105
+ Playbook load was in flight, `checkEntitlement` — and therefore `can()`,
106
+ `gate()`, `useCan`, `useEntitlement` and `useGatedAction` — returned the
107
+ fail-closed deny with reason `config_unavailable`, which is indistinguishable
108
+ from a rule denial. A gate mounted before config resolution rendered its paywall
109
+ and never re-evaluated, hiding a feature the user had paid for.
110
+
111
+ The entitlement path now takes the same two-state split:
112
+
113
+ | state | when | behaviour |
114
+ |---|---|---|
115
+ | **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. |
116
+ | **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`). |
117
+
118
+ The emission rule is the one worth stating plainly: **exactly one
119
+ `gate_evaluated` per settled verdict, and none for the transient state** — a
120
+ lost race is not an evaluation and must not enter the gate funnel as a denial.
121
+
122
+ **No API, wire or reason-code change.** `@public` signatures are unchanged
123
+ (`web-sdk/generated/public-api.json` is untouched), both reason codes keep their
124
+ meanings, and `tests/reason-contract.json` is unchanged. A `limited`-but-allowed
125
+ result is untouched; the fail-closed contract for terminal unavailability is
126
+ untouched.
127
+
128
+ **Python and Rust are unaffected by design**, for the same reason as BL-0177:
129
+ both take the Playbook as a required constructor argument, do no I/O, and have no
130
+ not-yet-loaded window.
131
+
132
+ **Landed in.** `0.11.3` (BL-0179, #529).
133
+
134
+ **Fail-closed in.** Not applicable — terminal behaviour is unchanged; only the
135
+ transient race is now absorbed.
136
+
137
+ **Proving test.** `web-sdk/react/useCan.config-race.test.tsx` (a gate mounted
138
+ mid-race ends on the rule's verdict, allow and deny) and
139
+ `web-sdk/entitlement-config-race.test.ts` (both states, the emission rule, and
140
+ the bounded post-bound retry for `PlacementController` and `EntitlementGate`,
141
+ reached by injecting a `0` wait bound rather than with fake timers).
142
+
143
+ ---
144
+
145
+ ### A slot no longer paints its fallback forever when it loses the cold-start config race (BL-0177)
146
+
147
+ **What changed.** `getPlacementDecision` returns `config_unavailable` when the
148
+ Playbook has not arrived, uncached, specifically so a retry can succeed — but
149
+ nothing retried. `useSurfaceSlot` / `FixedSurfaceSlot` decide once per mount, so
150
+ on a cold `revturbine_server` load, whenever the first decision beat the two-hop
151
+ bootstrap → `/api/sdk/config` chain, the slot rendered its `fallback`
152
+ permanently and every integration had to hand-roll a retry.
153
+
154
+ `config_unavailable` now has two distinct states and the SDK handles each:
155
+
156
+ | state | when | behaviour |
157
+ |---|---|---|
158
+ | **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. |
159
+ | **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. |
160
+
161
+ **No API, wire or reason-code change.** `config_unavailable` is still the reason
162
+ code, the decision shape is untouched, and `tests/reason-contract.json` is
163
+ unchanged. Customer code that already hand-rolls a retry keeps working — it is
164
+ now redundant, not wrong.
165
+
166
+ **Python and Rust are unaffected by design.** Both ports take the Playbook as a
167
+ required constructor argument and raise on a missing or malformed one
168
+ (`server-python/src/revturbine/sdk.py` `__init__`, `server-rust/src/sdk.rs`
169
+ `new`), perform no I/O, and never emit `config_unavailable` — there is no
170
+ not-yet-loaded window to retry. This is a browser/hosted-mode state only.
171
+
172
+ **Landed in.** `0.11.3` (BL-0177, #528).
173
+
174
+ **Fail-closed in.** Not applicable — the terminal behaviour is unchanged; only
175
+ the transient race is now absorbed.
176
+
177
+ **Proving test.** `web-sdk/placements/FixedSurfaceSlot.config-race.test.tsx`
178
+ (both states), plus `e2e/journey.transport.spec.ts`, whose two hand-rolled retry
179
+ loops were deleted — a retry loop reappearing there is the regression signal.
180
+
181
+ ---
182
+
50
183
  ## 0.11.1
51
184
 
52
185
  ### The generated schema *types* gained their `Playbook*` names (BL-0165)