@revturbine/sdk 0.11.6 → 0.11.8

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.8
51
+
52
+ ### `account_id` falls back to a PREFIXED user key instead of a bare user id (BL-0117)
53
+
54
+ **What changed.** Every clickstream event the SDK sends to `/api/track` used to
55
+ carry `account_id: userContext.account_id || userId`. `TrackEvent.account_id`
56
+ was a required, min-length-1 field, so an app that identified no account had to
57
+ send *something* — and what it sent was the **user** id, bare and
58
+ indistinguishable from a real account. `monetization_funnel` and `cohort_rollup`
59
+ build their account map out of `events_clickstream.account_id`, and experiment
60
+ analysis reads that column whenever `analysis_unit = 'account'`: every such row
61
+ contributed a bogus `user_id → user_id` account, so an account-grain readout
62
+ returned a user-grain n while looking perfectly valid. The treatment-interaction
63
+ lane had the same hole from the other side — the SDK sent no `account_id` at all
64
+ and the ingest route's own `account_id ?? user_id` fallback stamped a user id
65
+ into `placement_presentations.account_id`.
66
+
67
+ Kent ruled on it, 2026-09-25 (**D-13**): *"Keep the user-id fallback but
68
+ explicitly prefix it to make it obvious its a fallback key."*
69
+
70
+ So the fallback stays — a row with no identified account still needs an
71
+ attribution handle, and a hole in a join key is not an improvement — but it is
72
+ now **self-describing**:
73
+
74
+ ```text
75
+ identified account → account_id: "acct_acme"
76
+ no account → account_id: "user-fallback:user_123"
77
+ ```
78
+
79
+ Both wire lanes (`/api/track` → `events_clickstream.account_id`, and
80
+ `/api/events/interactions` → `placement_presentations.account_id`) resolve the
81
+ value through one private helper, so they cannot drift apart: the funnel joins
82
+ one against the other, and a divergence in trimming, hashing or fallback
83
+ derivation joins nothing. The fallback is derived from the same
84
+ `userContext.id || anonymousId` the row's `user_id` comes from and redacted the
85
+ same way, so an email-shaped user id is the SAME hash behind the marker on both
86
+ lanes. A blank or whitespace-only `account_id` is not an account and falls
87
+ through to the fallback.
88
+
89
+ Two new `@public` exports carry the contract to integrators and to anything
90
+ reading the warehouse:
91
+
92
+ ```ts
93
+ import { FALLBACK_ACCOUNT_ID_PREFIX, isFallbackAccountId } from '@revturbine/sdk';
94
+
95
+ FALLBACK_ACCOUNT_ID_PREFIX; // 'user-fallback:'
96
+ isFallbackAccountId('user-fallback:user_123'); // true — fabricated
97
+ isFallbackAccountId('acct_acme'); // false — a real account
98
+ ```
99
+
100
+ `isFallbackAccountId` is position- and case-sensitive by contract, matching the
101
+ read-time SQL guards: an account id that merely *contains* the marker is a real
102
+ account, and the bare prefix with nothing after it is not a key at all. The same
103
+ constant and predicate ship in `server-python`
104
+ (`revturbine.core.account_identity`) and `server-rust`
105
+ (`revturbine::account_identity`, re-exported at the crate root), and the
106
+ cross-language parity corpus drives all three
107
+ (`tests/parity/fixtures/account_id_fallback_prefix.json`) so the literal cannot
108
+ drift between ports.
109
+
110
+ **Absence is still valid on the wire.** `@revt-eng/schema` 0.1.325 (scaffold
111
+ [#375](https://github.com/revt-eng/revturbine-scaffold/pull/375)) made
112
+ `TrackEvent.account_id` optional, and web migration 014 made
113
+ `events_clickstream.account_id` nullable — absence means "no account
114
+ identified", and a producer with no user identity to derive from may still omit
115
+ the field. The browser SDK always has an identity (an un-identified visitor
116
+ still has the anonymous id), so its default emit always sends the prefixed
117
+ fallback rather than a hole.
118
+
119
+ No public API change to an existing signature: nothing moved, and nothing new is
120
+ asked of the caller. `identify(userId, { account_id })` is still the only way to
121
+ supply an account. Integrations that identify one see no wire change at all.
122
+
123
+ **Read-time consequence, stated plainly.** Account-grain analytics exclude
124
+ fallback-prefixed ids from account denominators, so an app that never identifies
125
+ an account now reports **zero** accounts rather than one-per-user. That is a
126
+ correction, not a regression — those rows were never accounts — but a dashboard
127
+ that silently read them as account-grain will show a drop.
128
+
129
+ **Landed in** `0.11.8`. **Fail-closed in** `0.11.8` — the bare fallback is gone
130
+ in the same version that introduced the prefixed one; there is no window where
131
+ both shapes are emitted.
132
+
133
+ **Proving test:** `web-sdk/account-identity.test.ts` (the literal, the
134
+ classifier's position/case sensitivity, and the bare-prefix and absence cases)
135
+ plus `web-sdk/interaction-wire-contract.test.ts` — "the account identity on the
136
+ /api/track wire" and "the account identity the analytics joins key on": an
137
+ identified user with no account produces `user-fallback:<user_id>` on both
138
+ lanes, the prefix survives `/api/track` JSON serialization byte-for-byte, an
139
+ identified account reaches the wire byte-identically to the value supplied, a
140
+ blank account id is treated as absence, an un-identified visitor derives from
141
+ the anonymous id, and every body parses against the canonical
142
+ `TrackEventSchema` / `TreatmentInteractionInputSchema`. Also
143
+ `web-sdk/customer-side-ingest.test.ts` on the plain `capture()` path,
144
+ `server-python/tests/test_account_identity.py`, the unit tests in
145
+ `server-rust/src/account_identity.rs`, and the three-way parity fixture.
146
+
147
+ ## 0.11.7
148
+
149
+ ### A rebuilt gate/slot re-runs its check, so a page inside `<Gate>` no longer renders blank (BL-0251)
150
+
151
+ **What changed.** `RevTurbineProvider` re-initializes and publishes a **new**
152
+ `sdk` instance whenever its `options` prop changes identity, with `isReady`
153
+ staying `true`. Hosts do this routinely: options derived from an async session
154
+ change once, shortly after first paint.
155
+
156
+ `useEntitlement` rebuilt its `EntitlementGate` on that change, but the effect
157
+ that *runs* the check keyed on `[autoCheck, isReady, handle, contextKey]` — none
158
+ of which move when only `sdk` does. The replacement gate was therefore never
159
+ checked, and an unchecked gate reports `result: null` with `isLoading: false`,
160
+ which `<Gate>` reads as *unresolved* and renders as `null`. A page whose whole
161
+ body sits inside a `<Gate>` rendered **nothing**, permanently, until the gate was
162
+ remounted — which is why navigating away and back "fixed" it and a hard refresh
163
+ hit it every time (the session settles after the gate mounts).
164
+
165
+ `usePlacement` had the same defect: `loadDecision` was memoized on
166
+ `[isReady, resolvedUserId]`, so a controller rebuilt for a new `sdk` **or** a
167
+ changed slot config was never loaded and the slot stayed empty for the life of
168
+ the mount.
169
+
170
+ Both auto-run dependency lists are now supersets of the lists that rebuild the
171
+ object they act on (`sdk` added to both; `placementKey` added to
172
+ `loadDecision`). No retry, no polling — the trigger simply covers every reason
173
+ the underlying object was replaced.
174
+
175
+ **Landed in.** `0.11.7`.
176
+
177
+ **Fail-closed in.** n/a — this was a fail-**blank**, not a tolerated old shape.
178
+ Nothing a host passes changes.
179
+
180
+ **Proving test.** `web-sdk/placements/AccessGateSurfaceSlot.sdk-rebuild.test.tsx`
181
+ and `web-sdk/react/usePlacement.sdk-rebuild.test.tsx`.
182
+
50
183
  ## 0.11.6
51
184
 
52
185
  ### `sdk.convert()` reloads the UserContext, so a mounted slot re-decides (BL-0004)