Skip to main content

The personalization slots model

On-site personalization in Devotel Orbit runs on three objects — slots, variants, and a lift report — plus one rule that decides which variant a visitor sees. The workspace how-to (defining slots, rendering with the web SDK, promoting a winner) is Personalize website content; this page is the concept underneath it: why the model is scoped the way it is, what the segment-label enum actually is, and why mis-naming a variant silently converts a clean experiment into noise. Read it once before you hand a list of variant names to measurement, and the numbers will mean what you think they mean.

What a slot is — scope and windows of resolution

A slot is a named, scoped placement — a location a page declares and the SDK fills in. The scope is yours to decide:
  • Page-scoped: hero-banner exists only on the homepage; cart-empty only on an empty cart. Page-scoped slots are the common case — one placement, one purpose.
  • Pattern-scoped: product-promo can appear on every product detail page. Pattern-scoped slots are reused placements.
  • Site-wide: nav-promo or announcement-bar render on every page. Site-wide slots are the tenant-wide overlay.
The scope lives in the slot name, not in any configuration field — the same slot name on different pages behaves differently by convention you agree in the team. That scoping matters because the slot name is also the measurement axis: a lift report compares arms on whichever slot the request names, and only slot events whose properties.slot equals that name count. Mix site-wide and page-scoped placements under the same slot name and the report pool’s arms no longer answer the same question. The window of resolution is the moment the SDK resolves the variant — once, when the page’s script asks for the slot, after the visitor has been identified. Every later render uses the resolved payload (content, cta_url, cta_text, variant) directly; there is no server round-trip on re-renders the page performs itself. This is a deliberate choice: personalization should not lay a second fetch on the render-critical path. Variants for a slot are managed under the slot-management endpoints; the model’s service surface is the Personalization API reference. Create one variant per slot + segment + variant triple, and keep one slot-default variant (see below) as the fallback — without it, browsers render nothing.

Variant types — the sentinel vs. named variants

Every variant row carries an optional variant string. The picker distinguishes exactly two shapes:
  • A named variant ('b', 'control', …) is assigned an identity. Any group of rows with the same name forms one named arm in the lift report.
  • The untagged variant (variant IS NULL) is “the default content” — the fallback every visitor may see when no named variant matches, and the arm the lift report calls the holdout.
From the model’s point of view this is not a special role — there is no boolean “is holdout” flag, and the database has no such column. The convention exists so the measurement layer has a fixed sentinel, __default__, to select “the visitor saw no variant-specific treatment” as the baseline arm. Inside the report query, __default__ is an exact predicate: variant IS NULL. Pass a named variant as the baseline and the report measures that variant; pass __default__ and it measures visitors the picker resolved to the untagged row. Holdout seeding is the act of dedicating an untagged variant as the experiment’s control: create the slot’s fallback, leave its variant unset, keep it active, and never tag it. The moment you tag the fallback row with a name like 'b' or 'a', its impressions stop being “untreated” in the measurement sense — they become just another named arm, indistinguishable from treatment. Keep the fallback untagged throughout an experiment so __default__ keeps meaning what it promises: “the content every visitor would have seen had there been no experiment.” This is also the reason the picker is exact and not a prefix match: a slot name passed to the picker and a slot name stored on the row must match exactly. A wrong-selection handshake (asking for slot hero-banner when the row says hero-main) resolves to no variant at all — the response carries an empty payload and the page keeps its own markup. Within the slot the fallback ladder has exactly one rung — segment-specific beats untagged, untagged beats nothing — and there is no fuzzy cross-slot guess past that ladder.

The segment-label enum — a fixed vocabulary, add-and-read

Variants target the contact’s lifecycle segment using a closed enum of eight labels: The enum lives in the contact scorer — the daily pass that blends engagement signals into per-contact scores and derives the label (see the contact scoring pipeline for the signals and cadence). Personalization accepts only these eight values; add-and-read semantics means you read the scorer’s vocabulary at slot-write time rather than add a new free-form label per slot. That is a deliberate contrast with the CDP segments API, which composes arbitrary audience definitions from attributes and events — a segments-object of your own. Slots target lifecycle truth, not ad-hoc audiences, so the picker can resolve with one indexed join against the contact’s score row instead of evaluating per-request segment filters. Canonicalization matters for three surfaces:
  1. Writing a variant: the API validates segment_label against the enum, so a misspelled label ('at-risk', 'atRisk') is rejected at write time, not silently never-matching at serve time.
  2. Reading variants: a stale label, dragged out of an old dashboard session, degrades to no filter on list/read queries — you see every variant rather than a 422. The picker treats an unknown label the same way: segment_label filters apply only when the label is recognized.
  3. Resolving visitors: the runtime endpoint picks the highest-priority active variant whose label matches the visitor’s segment; the untagged slot-default is the fallback. Because only the eight literals above are acceptable, one indexed join settles the ranking.
Keep segment names you invent for your own segments in the segments API; slots never consume them.

Lift measurement — Wald CI, two-proportion z-test, and the exact-not-default rule

The lift report answers one question: does the named variant beat the fallback (or another named variant) on the event you count as a conversion? Each render already logged an impression event with the slot and the variant it served, and each conversion event (clicked_cta, purchase, any name track() uses) is already in the same event sink. Measurement is a join, not new instrumentation. The report computes per arm:
  • Impressions — distinct visitors whose impression event carried this variant (or, for __default__, visitors whose impression event had no variant tag).
  • Conversions — of those visitors, how many also fired the conversion event in the same lookback window.
  • Conversion rate — conversions ÷ impressions.
Across the two arms it reports absolute lift (percentage points), relative lift, a Wald 95% confidence interval on the absolute difference, a two-proportion z-score, a p-value, and an is_significant flag that is true exactly when the interval excludes zero. state is no_traffic while neither arm has impressions, collecting while only one does — the statistical fields stay null rather than produce NaN-grade output until both arms have traffic. The exact-not-default rule is what keeps that report honest. Because the report’s baseline arm is whichever predicate you pass, a baseline of 'b' measures variant b and a baseline of __default__ measures the untagged fallback — but nothing else. Mis-attributed holdout treatment degrades the measurement in a specific way: if your fallback row got tagged with a name during a rename or an import, and you still pass __default__, the report’s baseline arm contains only other untagged traffic — potentially none — while the treatment arm contains the variant you actually meant to compare against the fallback. The report then compares treatment against visitors who saw neither — a missing-baseline confound, not an artifact a significance flag can rescue. Seed holdouts by leaving the fallback untagged, and pass __default__ only while that is true. The same filter that lets unknown segment labels degrade to no-filter applies here too: a stale segment_label on the report request widens the window rather than failing it, so a renamed segment does not hard-fail the measurement — it just widens the population.

Preview mode — the picker’s deterministic-enough semantics

Previewing a slot runs the same selection as the live runtime endpoint, with nothing published and no live page — pass a segment label and you get back the variant that would win for that segment; omit it and you get back the slot-default; pass an unknown label and you again get the slot-default because the label widens to no-filter. resolved is null when no active variant matches, which is the SDK’s “leave your own markup alone” signal. “Deterministic-enough” names the two leniencies the picker deliberately ships: (1) an unknown segment label degrades to no-filter rather than erroring (lists, previews, and the lift report all share this), and (2) priority is a tie-breaker, not a routing rule — when a segment-specific variant and the untagged fallback both match, the picker prefers the segment-specific row, and priority ranks ties within the two halves. Neither leniency changes the one-way door: exact slot-name match decides whether anything is served at all. Use the preview to confirm a variant binds before you activate it; use the runtime behavior for what visitors actually see.

Further reading