<?xml version="1.0" encoding="utf-8"?><rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><language>en</language><title>Blog posts by Piotr Nowak - Optimizely & Azure</title> <link>https://world.optimizely.com/blogs/piotr-nowak---optimizely--azure/</link><description></description><ttl>60</ttl><generator>Optimizely World</generator><item> <title>Content Variations in CMS 13, Part 3: Audiences vs Audiences</title>            <link>https://world.optimizely.com/blogs/piotr-nowak---optimizely--azure/dates/2026/6/content-variations-in-cms-13-part-3-audiences-vs-audiences/</link>            <description>&lt;blockquote style=&quot;margin: 24px 0; padding: 18px 20px; background: #f5f5f4; border: 1px solid #e7e5e4; border-radius: 6px;&quot;&gt;
&lt;p&gt;&lt;strong&gt;Executive summary.&lt;/strong&gt; Part 2 left the experiment running against &lt;em&gt;Everyone&lt;/em&gt;. Real projects don&#39;t look like that. So this part wires those same CMS Content Variations to two rival audience engines and measures what each actually does. Every number below comes from a live CMS 13.1.0 + FX SDK instance. A targeted delivery served &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_a&lt;/span&gt; to 60 of 60 mobile visitors; at 100% allocation that is deterministic, so a single miss would be a bug rather than noise. The desktop experiment held its 33/33/34 split (&amp;chi;&amp;sup2; = 1.465, n = 300). The MVC head and the headless Next.js head agreed on the arm &lt;em&gt;and&lt;/em&gt; the rule for 20 of 20 visitor/device pairs, with zero coordination code. The same runs found the boundary. CMS Audiences evaluate only inside the CMS runtime: a personalized block vanished from Optimizely Graph entirely, invisible to every headless consumer, and nothing threw an error. If your requirement says &amp;ldquo;both heads&amp;rdquo;, the audience decision is made before anyone asks about features.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;em&gt;Parts 1 and 2 built the machine: Content Variations as the quiet hero of CMS 13, then Feature Experimentation as its stats engine, with one string, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;VariationKey == variation name&lt;/span&gt;, as the entire integration contract. This part answers the question that contract postpones: who decides who the visitor is? CMS 13 has an answer. FX has a different answer. Both are called Audiences now, and they are not the same thing. Everything below ran on CMS 13.1.0 with the C# SDK 4.3.0 and the JavaScript SDK 6.4.0. Every number is from those runs.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;In this part:&lt;/strong&gt; the Audiences naming collision &amp;middot; the three-layer opt-in (and the startup self-check it forced) &amp;middot; the five-attribute pipeline with native geolocation &amp;middot; targeted delivery semantics the docs bury &amp;middot; the coexistence proof and the Graph void &amp;middot; the day the headless head went dark &amp;middot; three QA override levels &amp;middot; a troubleshooting runbook &amp;middot; eight sharp edges ranked by blood &amp;middot; FAQ &amp;middot; glossary.&lt;/p&gt;
&lt;h2&gt;The machine at a glance&lt;/h2&gt;
&lt;p&gt;One picture before the prose. Both heads serve the same experiment from the same definitions. The only shared state is configuration-as-data:&lt;/p&gt;
&lt;figure style=&quot;margin: 24px auto; text-align: center; max-width: 800px;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-3/machine-at-a-glance.svg&quot; alt=&quot;Both heads read the same FX datafile and CMS Graph index. The MVC head runs Decide, loads the CMS variation and renders, with the CMS Audience filtering content-area items server-side. The Next.js head runs the same decision and renders via a Graph query, but the personalized items are absent from Graph.&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;&lt;em&gt;Both heads serve the same experiment from the same definitions; only the Graph path drops the personalized item. The CMS Audience layer has no representation on the headless route.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The CMS Audience layer simply has no representation on the Graph path. The rest of this article is the evidence for every arrow.&lt;/p&gt;
&lt;h2&gt;Two engines, one word: both of them are called Audiences now&lt;/h2&gt;
&lt;p&gt;Start with the vocabulary trap, because it will find you anyway. Visitor Groups have been rebranding to &lt;strong&gt;Audiences&lt;/strong&gt; since the CMS 12 admin redesign. CMS 13 finishes the job: the admin package describes itself as the &amp;ldquo;audiences management UI&amp;rdquo;, the docs say &amp;ldquo;an audience (formerly called a visitor group)&amp;rdquo;, and the old name survives mostly in role names and API types. Feature Experimentation has shipped &lt;strong&gt;Audiences&lt;/strong&gt; for years. Same word, two engines, and they answer the same question with opposite architectures:&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;&amp;nbsp;&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;CMS Audiences (formerly Visitor Groups)&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;FX Audiences&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Evaluated by&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;The CMS, server-side, during rendering&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Every SDK, in-process, at &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Decide()&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Evaluation input&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;IPrincipal&lt;/span&gt; + &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;HttpContext&lt;/span&gt;, full request, roles, visit history&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Only the attributes your code passed at context creation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Definition lives in&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;CMS admin (&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;VisitorGroupAdmins&lt;/span&gt; role)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;FX dashboard, ships in the datafile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Personalizes&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;A &lt;strong&gt;fragment&lt;/strong&gt;, one content-area item&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;A &lt;strong&gt;page version&lt;/strong&gt;, via the variation key contract from part 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Works headless&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;No, and the docs say so out loud&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Yes, the evaluation is a pure function, portable by construction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Measurement&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&amp;ldquo;Enable statistics&amp;rdquo; view counts, with no exposures, no conversions, no significance&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;The stats engine: exposures, conversions, significance&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The last two rows are the ones that matter. A CMS Audience can reach into everything the CMS knows about the request, and pays for it by existing only where the CMS renders. An FX Audience knows nothing you didn&#39;t tell it, and pays for &lt;em&gt;that&lt;/em&gt; with a pipeline you must build. But the function (datafile + attributes) &amp;rarr; bool runs identically in C#, in Node, in anything with an SDK. Keep that trade in view. Every decision below falls out of it.&lt;/p&gt;
&lt;p&gt;One governance row to add to part 2&#39;s who-does-what table: audience definitions get owners too. CMS Audiences belong to whoever holds &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;VisitorGroupAdmins&lt;/span&gt;. FX Audiences belong to the dashboard. Attribute &lt;em&gt;keys&lt;/em&gt;, though, are an API contract between the codebase and the dashboard: a developer renames &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;device&lt;/span&gt; and the marketer&#39;s audience quietly stops matching. Write them down somewhere both sides actually read.&lt;/p&gt;
&lt;h2&gt;Switching the engine on: in CMS 13, personalization is opt-in three layers deep&lt;/h2&gt;
&lt;p&gt;Part 2&#39;s sharp-edges list opened with a package that crashed CMS 13 at startup. This part&#39;s equivalent is gentler and stranger: in CMS 13, Visitor Groups are not in the box you already have. The &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;EPiServer.CMS&lt;/span&gt; metapackage ships &lt;strong&gt;neither the evaluation core nor the UI&lt;/strong&gt;. The install docs state the philosophy plainly: &amp;ldquo;every NuGet package your project references must have its services explicitly registered, or the application fails at startup.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;The documented pair of calls is &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;services.AddVisitorGroupsMvc().AddVisitorGroupsUI()&lt;/span&gt;, which also adds the &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;EPiServer.CMS.UI.VisitorGroups&lt;/span&gt; 13.1.0 package.&lt;/p&gt;
&lt;p&gt;I learned what each one carries the honest way, by registering less than that. &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;AddVisitorGroupsUI()&lt;/span&gt; alone boots clean. Then the Audiences screen throws &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Unable to resolve service for type &#39;IVisitorGroupCriterionRepository&#39;&lt;/span&gt; the moment an editor clicks it: the screen&#39;s own API services, the ones that load criterion lists and persist rules, resolve against registrations the UI package does not carry. A UI that renders is not a UI that saves. Registering the two internal layers that error message points at fixes the admin screen. It also quietly skips the &lt;em&gt;rendering&lt;/em&gt; layer, so personalized blocks would render for everyone. That&#39;s the worst possible failure mode: a working configuration surface for a filter that doesn&#39;t run. &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;AddVisitorGroupsMvc()&lt;/span&gt; wraps all of it: repositories, the built-in criteria, the content-area rendering filter, and the &amp;ldquo;View as Audience&amp;rdquo; impersonation service.&lt;/p&gt;
&lt;p&gt;Notice what the docs promised and what actually happened. The promise: misregistration &lt;em&gt;fails at startup&lt;/em&gt;. The observation: it failed at click time, with a green boot log. So the demo project now resolves the services the Audiences API needs at startup, in Development, and logs every registered criterion: &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;DeviceCriterion, DisplayChannelCriterion, &amp;hellip; TimeOfDayCriterion, &amp;hellip;&lt;/span&gt; (22 of them), making the documentation&#39;s promise true at exactly the layer where it broke. Twenty-two criteria, including &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;RoleCriterion&lt;/span&gt; and &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;UserProfileCriterion&lt;/span&gt; that the docs list doesn&#39;t mention, plus &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;DeviceCriterion&lt;/span&gt;, which is ours. The whole class fits on a slide, and it earns the line this demo is built around: one definition of &amp;ldquo;who you are&amp;rdquo;, two engines reading it.&lt;/p&gt;
&lt;pre style=&quot;background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;&quot;&gt;[VisitorGroupCriterion(
    Category = &quot;Technical&quot;,
    DisplayName = &quot;Device class&quot;,
    Description = &quot;Matches the visitor&#39;s device class (mobile / tablet / desktop) &quot;
                + &quot;using the same User-Agent heuristic that feeds the FX &#39;device&#39; attribute.&quot;)]
public class DeviceCriterion(IVisitorAttributesProvider attributesProvider)
    : CriterionBase&amp;lt;DeviceCriterionModel&amp;gt;
{
    public override bool IsMatch(IPrincipal principal, HttpContext httpContext)
    {
        // Editors type the value by hand - trim + lowercase forgives &quot;Mobile &quot;
        // (the FX exact matcher would not; see sharp edge #1).
        var expected = Model.Device?.Trim().ToLowerInvariant();
        return !string.IsNullOrEmpty(expected)
               &amp;amp;&amp;amp; attributesProvider.GetAttributes().TryGetValue(&quot;device&quot;, out var device)
               &amp;amp;&amp;amp; Equals(device, expected);
    }
}&lt;/pre&gt;
&lt;p&gt;Constructor injection works (the built-in geographic criteria take &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;IClientGeolocationResolver&lt;/span&gt; the same way), &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;[VisitorGroupCriterion]&lt;/span&gt; registers the class through plugin scanning, and the model is &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;CriterionModelBase&lt;/span&gt; with one string property and a &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Copy() =&amp;gt; ShallowCopy()&lt;/span&gt;. The built-in &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;OSBrowserCriterion&lt;/span&gt; could classify devices without custom code. Ours exists for consistency with the FX attribute, not for extra capability.&lt;/p&gt;
&lt;h3&gt;Reference card: the CMS 13 personalization bill of materials&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;Call&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;Package&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;What it actually registers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;AddVisitorGroupsMvc()&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;EPiServer.Cms.AspNetCore.Mvc&lt;/span&gt; (already referenced)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;The whole working engine: repositories and statistics, role infrastructure, the built-in criteria, the content-area &lt;strong&gt;rendering filter&lt;/strong&gt;, fragment handlers, View-as-Audience impersonation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;AddVisitorGroupsUI()&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;EPiServer.CMS.UI.VisitorGroups&lt;/span&gt; (add it; not in the metapackage)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;The Audiences management screen (protected module &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;VisitorGroups.zip&lt;/span&gt;) and its API controllers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;AddCmsClientGeolocation(o =&amp;gt; o.LocationHeader = &amp;hellip;)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;EPiServer.Geolocation&lt;/span&gt; (already referenced)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Header-based geolocation: feeds the geographic criteria &lt;em&gt;and&lt;/em&gt; anything else consuming &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;IClientGeolocationResolver&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;[VisitorGroupCriterion]&lt;/span&gt; on your class&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;your project&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Custom criteria via plugin scanning; explicit &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;AddVisitorGroupsCriterion&amp;lt;T&amp;gt;()&lt;/span&gt; only if you disable scanning&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;And the criteria catalog as measured on 13.1.0, the self-check log, deduplicated and grouped, because no docs page currently lists all of them:&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;Group&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;Criteria&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Behavior&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;NumberOfVisits, ViewedPages, ViewedCategories, Download, TimeOnSite, Event&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Arrival&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Referrer, SearchWordReferrer, StartUrl, QueryString&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Time&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;TimeOfDay, TimePeriod&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Place &amp;amp; client&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;GeographicLocation, GeographicCoordinate, IPRange, OSBrowser, DisplayChannel, SelectedLanguage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Identity&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Role, UserProfile, VisitorGroupMembership&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Custom (this demo)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;DeviceCriterion&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Two of those built-ins, configured: a time-of-day window and a country match, both reading request and CMS state the FX engine never sees.&lt;/p&gt;
&lt;figure style=&quot;margin: 24px auto; text-align: center; max-width: 800px;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-3/vg-criterion-timeofday.png&quot; alt=&quot;Built-in Time of Day criterion on an Office hours CMS audience: 08:00-16:00, Monday to Friday.&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;&lt;em&gt;Built-in Time of Day criterion on an &amp;ldquo;Office hours&amp;rdquo; CMS audience: 08:00&amp;ndash;16:00, Monday to Friday.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure style=&quot;margin: 24px auto; text-align: center; max-width: 800px;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-3/vg-criterion-geo.png&quot; alt=&quot;Built-in Geographic Location criterion on a Visitors from PL CMS audience: Europe / Poland.&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;&lt;em&gt;Built-in Geographic Location criterion on a &amp;ldquo;Visitors from PL&amp;rdquo; CMS audience: Europe / Poland.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2&gt;The attribute pipeline: FX only knows what you tell it&lt;/h2&gt;
&lt;p&gt;The part-2 provider sent two attributes derived from the request: &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;device&lt;/span&gt; from the User-Agent, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;location&lt;/span&gt; from geo headers. Part 3 grows it to five, and the growth is where the lessons are:&lt;/p&gt;
&lt;pre style=&quot;background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;&quot;&gt;return new Dictionary&amp;lt;string, object?&amp;gt;
{
    [&quot;device&quot;]    = ResolveDevice(request),          // &quot;mobile&quot; | &quot;tablet&quot; | &quot;desktop&quot;
    [&quot;location&quot;]  = ResolveLocation(context),        // &quot;PL&quot;, &quot;SE&quot;, ... | &quot;unknown&quot;
    [&quot;logged_in&quot;] = context?.User.Identity?.IsAuthenticated == true,   // a real bool
    [&quot;cms_role&quot;]  = ResolveCmsRole(context?.User),   // &quot;admin&quot; | &quot;editor&quot; | &quot;none&quot;
    [&quot;consent&quot;]   = HasConsent(request)              // a real bool, from a cookie
};&lt;/pre&gt;
&lt;figure style=&quot;margin: 24px auto; text-align: center; max-width: 800px;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-3/fx-attributes.png&quot; alt=&quot;FX dashboard, Settings to Audiences to Attributes: the five registered attribute keys.&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;&lt;em&gt;The five registered attribute keys (&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;cms_role&lt;/span&gt;, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;consent&lt;/span&gt;, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;device&lt;/span&gt;, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;location&lt;/span&gt;, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;logged_in&lt;/span&gt;), the code-to-dashboard contract.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Three rules. All three carried more weight than I expected when I wrote them down. (Throughout this article, &amp;ldquo;the panel&amp;rdquo; means part 2&#39;s instrument set. The demo page renders a &lt;em&gt;variation pill&lt;/em&gt; carrying the served arm plus a &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;data-rule&lt;/span&gt; attribute naming the rule that served it, and a Development-only &lt;em&gt;diagnostics panel&lt;/em&gt; showing the visitor ID, the attribute dictionary and the SDK&#39;s &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Decide&lt;/span&gt; reasons. Every measurement below reads off those two surfaces.)&lt;/p&gt;
&lt;figure style=&quot;margin: 24px auto; text-align: center; max-width: 800px;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-3/demo-diagnostics.png&quot; alt=&quot;The variation pill and the FX decision diagnostics.&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;&lt;em&gt;&amp;ldquo;The panel&amp;rdquo;: the variation pill and the FX decision diagnostics: visitor ID, served variation, the attribute dictionary, and the SDK INCLUDE_REASONS trace.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;&lt;strong&gt;Types are the contract.&lt;/strong&gt; Attributes are untyped in the dashboard. The value you &lt;em&gt;send&lt;/em&gt; decides which comparisons can match. Send the string &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;&quot;true&quot;&lt;/span&gt; against a boolean condition and the condition is skipped: evaluated to UNKNOWN, audience false, visitor falls through, nobody logs an error at default verbosity. The booleans above are real booleans for exactly that reason. (Sharp edge #3 has the measurement.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Absent and false are different inputs.&lt;/strong&gt; An anonymous visitor on the MVC head sends &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;logged_in = false&lt;/span&gt;. If the headless head simply omitted the key, an audience targeting &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;logged_in is false&lt;/span&gt; would match on one head and skip on the other. Same visitor, same flag, different arms, and you would hunt the bug in the hashing where it isn&#39;t. The Next.js head therefore sends the constants its reality justifies: its visitors are, truthfully, never logged in.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One request, one context, attributes enter once.&lt;/strong&gt; The decision service is request-scoped and builds the SDK user context a single time. The banner flag, the experiment and the conversion all agree on who the visitor is. Change an attribute mid-request and nothing happens. That&#39;s by design, and worth a comment in the code so nobody &amp;ldquo;fixes&amp;rdquo; it.&lt;/p&gt;
&lt;h3&gt;Geolocation: one header now feeds both engines&lt;/h3&gt;
&lt;p&gt;The hand-rolled four-header geo parser from part 2 is gone. CMS 13 ships native client geolocation: country from a single configured CDN header, no local IP database. One line wires it: &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;services.AddCmsClientGeolocation(o =&amp;gt; o.LocationHeader = &quot;CF-IPCountry&quot;)&lt;/span&gt;.&lt;/p&gt;
&lt;p&gt;The same &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;IClientGeolocationResolver&lt;/span&gt; now answers two callers: our FX &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;location&lt;/span&gt; attribute and the built-in geographic criterion of CMS Audiences. One configuration line, two engines. That also means one spoofed header fools both at once. &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;curl -H &quot;CF-IPCountry: SE&quot;&lt;/span&gt; and the panel reads &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;location=SE&lt;/span&gt;. The part-2 caveat about attacker-supplied input didn&#39;t go away. It doubled its blast radius. And a detail measured the hard way: the resolver wants an &lt;strong&gt;uppercase&lt;/strong&gt; ISO code. &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;PL&lt;/span&gt; resolves; &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;pl&lt;/span&gt; resolves to nothing. Cloudflare sends uppercase, so production works. But any homegrown proxy that normalizes headers to lowercase turns your geographic targeting off, &lt;em&gt;both engines&#39; worth of it&lt;/em&gt;, with no warning, no log line, no sound. The diagnostics panel reading &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;location=unknown&lt;/span&gt; while the header is plainly there is the only tell.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The consent attribute is not an attribute.&lt;/strong&gt; Or rather: it is targetable like any other, but its real job is a switch. Impression and conversion events carry the visitor ID and the attribute dictionary to Optimizely&#39;s backend. That payload is precisely the thing a non-consenting visitor declined. So when &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;consent&lt;/span&gt; is false, the service calls &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Decide&lt;/span&gt; with &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;DISABLE_DECISION_EVENT&lt;/span&gt; and turns &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Track&lt;/span&gt; into a logged no-op. Flags keep working, because the datafile evaluation is local, but nothing leaves the building. Consent isn&#39;t an audience attribute. It&#39;s an event-egress switch. (The demo toggles it with a cookie; a real implementation wires it to your CMP. And the conversion button now reports &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;tracked: false&lt;/span&gt; instead of celebrating an event that never left. A demo that lies to you is worse than no demo.) One boundary disclosed rather than implied: the visitor-ID cookie itself is set, and bucketing happens, before any consent. This build treats the identifier as functional state and gates only what leaves the building. Your CMP, or your DPO, may read that line differently. That conversation belongs in your project rather than an SDK option.&lt;/p&gt;
&lt;p&gt;One honest note before moving on: FX has no server-side bot filtering. A crawler&#39;s User-Agent contains no &amp;ldquo;Mobi&amp;rdquo;, so every bot enters your experiment as a desktop visitor and dilutes whatever it touches. Either gate &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Decide&lt;/span&gt; on known-bot UAs, or accept the noise with your eyes open.&lt;/p&gt;
&lt;h2&gt;Targeted Delivery: personalization without the dice&lt;/h2&gt;
&lt;p&gt;The plan was seductively simple: put a targeted rule &lt;em&gt;above&lt;/em&gt; the everyone-else A/B test. Mobile gets the personalized arm, everyone else keeps experimenting. The dashboard said no, and the way it says no is the finding. The ruleset isn&#39;t one sortable list. It&#39;s three fixed sections whose headings literally narrate the evaluation order:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;First, match experiment rule&lt;/strong&gt; &amp;rarr; &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;a_b_test&lt;/span&gt; (A/B, audience: Non-mobile visitors)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Then, the following rules will be evaluated for all visitors&lt;/strong&gt; &amp;rarr; &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;mobile_delivery&lt;/span&gt; (deliver &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_a&lt;/span&gt;, 100%)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Then, for everyone else&lt;/strong&gt; &amp;rarr; Off&lt;/li&gt;
&lt;/ol&gt;
&lt;figure style=&quot;margin: 24px auto; text-align: center; max-width: 800px;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-3/ruleset-layered.png&quot; alt=&quot;The ruleset three fixed sections: experiment first, then deliveries for all visitors, then everyone-else.&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;&lt;em&gt;The ruleset&#39;s three fixed sections: experiment first, then deliveries for all visitors, then everyone-else. No shared list to drag a delivery above an experiment.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure style=&quot;margin: 24px auto; text-align: center; max-width: 800px;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-3/rule-targeted.png&quot; alt=&quot;The mobile_delivery rule: Targeted Delivery, audience Mobile visitors, 100%, deliver variant_a.&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;&lt;em&gt;The &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;mobile_delivery&lt;/span&gt; rule: Targeted Delivery, audience Mobile visitors, 100%, deliver &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_a&lt;/span&gt;.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure style=&quot;margin: 24px auto; text-align: center; max-width: 800px;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-3/fx-audience-mobile.png&quot; alt=&quot;The Mobile visitors FX audience: device equals mobile.&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;&lt;em&gt;The Mobile visitors FX audience: &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;device&lt;/span&gt; equals &amp;ldquo;mobile&amp;rdquo;, with all five attribute keys in the browser on the right.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure style=&quot;margin: 24px auto; text-align: center; max-width: 800px;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-3/fx-audience-codemode.png&quot; alt=&quot;The same audience in Code Mode: match_type exact.&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;&lt;em&gt;The same audience in Code Mode: &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;match_type&lt;/span&gt; &amp;ldquo;exact&amp;rdquo;. The datafile the SDKs evaluate spells the field &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;match&lt;/span&gt; (sharp edge #1).&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure style=&quot;margin: 24px auto; text-align: center; max-width: 800px;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-3/fx-audience-nonmobile.png&quot; alt=&quot;The Non-mobile visitors audience: device equals desktop OR tablet.&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;&lt;em&gt;The Non-mobile visitors audience used to carve the experiment: a positive list, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;device&lt;/span&gt; equals desktop OR tablet, so an unknown device matches nothing and lands on master.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;FX evaluates experiments before deliveries. Always. The UI encodes the law in its page structure rather than enforcing it with an error. So carving mobile out of the experiment happens in the &lt;em&gt;audience&lt;/em&gt;, not in rule order. The A/B rule&#39;s audience changed from &lt;em&gt;Everyone&lt;/em&gt; to &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Non-mobile visitors&lt;/span&gt;: a deliberately positive list (&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;device = desktop OR tablet&lt;/span&gt;) rather than a negation. That way a visitor with a missing or mangled &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;device&lt;/span&gt; attribute matches &lt;em&gt;nothing&lt;/em&gt; and lands on master, which is the honest outcome for &amp;ldquo;we don&#39;t know who this is&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;Two semantics from the docs that almost nobody quotes, both load-bearing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;experiment&lt;/strong&gt; whose audience matches but whose traffic allocation misses &lt;em&gt;rolls down&lt;/em&gt; to the next rule. A &lt;strong&gt;delivery&lt;/strong&gt; in the same situation &lt;em&gt;jumps straight to everyone-else&lt;/em&gt;, skipping any deliveries below it.&lt;/li&gt;
&lt;li&gt;Deliveries produce &lt;strong&gt;no Results page&lt;/strong&gt;: &amp;ldquo;No decision events show up on the results page.&amp;rdquo; A delivery is deployment, not measurement. Our mobile visitors get their personalization and leave the experiment&#39;s bookkeeping entirely. The A/B sample quietly becomes a desktop-and-tablet sample. Write that sentence in your analysis doc before someone asks why the experiment&#39;s traffic dropped.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And one behavioral nuance that will eventually puzzle a stakeholder: bucketing is sticky, attributes are not. The same visitor who taps &amp;ldquo;Request desktop site&amp;rdquo; in mobile Chrome flips &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;device&lt;/span&gt; mid-cookie, exits the delivery, and enters the experiment. Same visitor ID, different arm, both serves correct. Attribute-driven targeting re-evaluates every request. Only the hash is forever.&lt;/p&gt;
&lt;p&gt;Configuration footnote from the audience builder: the dashboard&#39;s Code Mode validates conditions with the key &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;match_type&lt;/span&gt;, while the datafile the SDKs download spells the very same field &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;match&lt;/span&gt;. Two serializations of one concept. Harmless until you copy a condition from the datafile into Code Mode and the validator rejects what the SDK just evaluated. The SDK&#39;s own log line later in this article shows the &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;match&lt;/span&gt; spelling in the wild.&lt;/p&gt;
&lt;h2&gt;Can both engines coexist on one page? Yes, at different layers, measurably&lt;/h2&gt;
&lt;p&gt;This is the part people actually google, so here is the layered setup, built and measured. FX picks the &lt;em&gt;page version&lt;/em&gt; (mobile &amp;rarr; &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_a&lt;/span&gt; via the delivery), and inside that version one block carries a CMS Audience (&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Mobile visitors (CMS)&lt;/span&gt;, our &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;DeviceCriterion&lt;/span&gt;). Two engines, two layers, same page.&lt;/p&gt;
&lt;figure style=&quot;margin: 24px auto; text-align: center; max-width: 800px;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-3/vg-audiences-list.png&quot; alt=&quot;CMS Audiences admin: Mobile visitors (CMS), Office hours, Visitors from PL.&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;&lt;em&gt;CMS Audiences admin: Mobile visitors (CMS), Office hours, Visitors from PL.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure style=&quot;margin: 24px auto; text-align: center; max-width: 800px;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-3/vg-admin.png&quot; alt=&quot;The Mobile visitors (CMS) audience on the custom Device class criterion.&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;&lt;em&gt;The Mobile visitors (CMS) audience on the custom Device class criterion (value &amp;ldquo;mobile&amp;rdquo;), statistics enabled.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure style=&quot;margin: 24px auto; text-align: center; max-width: 800px;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-3/vg-personalize.png&quot; alt=&quot;Assigning the audience to a content-area block inside variant_a in the CMS editor.&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;&lt;em&gt;Assigning the audience to a content-area block inside &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_a&lt;/span&gt; in the CMS editor, with the rendered variant on the right.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The measurement needed care, because FX variation noise would drown the block signal. Take one visitor ID that desktop-buckets into &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_a&lt;/span&gt; &lt;em&gt;through the experiment&lt;/em&gt;, then request the page twice: same visitor, same page version, only the User-Agent differs.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;Probe (same visitor, same &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_a&lt;/span&gt;)&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;Rule that served it&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;Content area&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Desktop&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;a_b_test&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;empty, block filtered out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Mobile&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;mobile_delivery&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&amp;ldquo;Mobile Quiet Hero&amp;rdquo; renders&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Pure visitor-group effect, isolated from FX. The coexistence answer: &lt;strong&gt;no conflict, different layers&lt;/strong&gt;. The FX decision happens in the controller before rendering. The CMS Audience filters content-area items during rendering. The pitfall isn&#39;t a conflict, it&#39;s unreachability: personalize a block for mobile in the &lt;em&gt;master&lt;/em&gt; version and no one will ever see it, because under this ruleset mobile never receives master. The FX rule one layer up decides which content area exists at all.&lt;/p&gt;
&lt;p&gt;There&#39;s a sour bonus measurement. An earlier build of this demo, the one registered from decompiled internals instead of the documented &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;AddVisitorGroupsMvc()&lt;/span&gt;, served that personalized block to the desktop probe too. Same content, same audiences, one missing rendering filter, zero errors. Keep that pair of runs. It&#39;s the cleanest argument I own for &amp;ldquo;configure from the docs, not from the decompiler&amp;rdquo;.&lt;/p&gt;
&lt;h3&gt;What does Content Graph actually do with a personalized block?&lt;/h3&gt;
&lt;p&gt;The same page travels to the Next.js head through Optimizely Graph. The personalized item doesn&#39;t:&lt;/p&gt;
&lt;pre style=&quot;background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;&quot;&gt;variant_a  &amp;rarr;  MainContentArea: []        (the block the MVC head demonstrably renders)
original   &amp;rarr;  MainContentArea: []
control    &amp;rarr;  MinimalPage.HeroArea: [{ &quot;Heading&quot;: &quot;EN Heading&quot; }]   // non-personalized area expands fine&lt;/pre&gt;
&lt;figure style=&quot;margin: 24px auto; text-align: center; max-width: 800px;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-3/graph-void.png&quot; alt=&quot;Optimizely Graph: all three DemoPage arms are in the index with distinct DemoTitle, yet every MainContentArea is empty.&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;&lt;em&gt;Optimizely Graph: all three DemoPage arms (variant_a, variant_b, master) are in the index with distinct DemoTitle, yet every MainContentArea is &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;[]&lt;/span&gt;. The page variation survives; the personalized content-area relation does not.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Not a leak. A void. The VG-personalized item vanishes from the index for &lt;em&gt;every&lt;/em&gt; Graph consumer, including the mobile visitors who would have matched. No API error, no warning in the sync job, nothing for the front-end to even detect. The shape of the void is worth a second look: the &lt;em&gt;block itself&lt;/em&gt; is still in the index, four &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;QuietHeroBlock&lt;/span&gt; documents, each with its own identity and content, but the page&#39;s content-area &lt;strong&gt;relation&lt;/strong&gt; to it is gone. The explanation consistent with every measurement is that indexing evaluates the page without a visitor to personalize for, and an audience-gated item has no honest answer to &amp;ldquo;should this exist?&amp;rdquo;, so it doesn&#39;t. Whatever the internals, the contract you can rely on is the measured one: personalization severs the composition, not the content. The docs say it without decoration: &amp;ldquo;audiences do not work on headless sites.&amp;rdquo; The measurement above is what that sentence costs in practice.&lt;/p&gt;
&lt;p&gt;So what do you actually do when the roadmap says headless? There are three shapes, in order of preference. First, move that personalization up a layer to FX Audiences and Content Variations. That is this article&#39;s whole thesis, and it needs nothing new. Second, take the CMS 13.1.0 Graph Conventions API and customize indexing so the head receives the items plus enough metadata to filter client-side. Workable, but now &lt;em&gt;you&lt;/em&gt; own the audience semantics on every head. Third, stand up a membership endpoint on the CMS that the head consults per request. It works, and it is exactly the coordination code this series exists to avoid. There is no fourth option where the block just shows up.&lt;/p&gt;
&lt;h3&gt;The decision table&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;You need&amp;hellip;&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;Engine&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;The same personalization on every head, today and after the next re-platform&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;FX Audiences + Content Variations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Criteria that read CMS state: roles, visit history, time of day&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;CMS Audiences&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Measurement: exposures, conversions, significance&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;FX (deliveries excluded, they don&#39;t report)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Kill switch without a deploy&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;FX (latency = datafile propagation; measured at ~75 s here)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Fragment-level personalization, CMS-rendered site, no measurement need&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;CMS Audiences&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Zero marginal license cost&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;CMS Audiences (in the CMS license; FX is usage-billed, though deliveries fire no impressions, only experiment bucketing does)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Per-request evaluation cost approaching zero&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;FX (pure in-memory function; VG criteria may do I/O per content-area item)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Ownership by content editors: instant, local, visual&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;CMS Audiences (&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;VisitorGroupAdmins&lt;/span&gt;; no deploy, no datafile, changes land on publish)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Ownership by product managers and analysts&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;FX Audiences, with attribute-key discipline as the price: keys are a code-to-dashboard contract, and once created an attribute stays in the datafile permanently&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A word of architectural restraint: a personalized block inside an experiment variation multiplies your test matrix: arms &amp;times; audiences &amp;times; consent states. The demo does it to prove the layering. Production teams should default to &lt;strong&gt;one personalization axis per page&lt;/strong&gt;, and budget QA explicitly before crossing them.&lt;/p&gt;
&lt;p&gt;ODP deserves its one paragraph: when the attributes should come from customer &lt;em&gt;data&lt;/em&gt; rather than the current request, Real-Time Segments for Feature Experimentation syncs ODP audiences into the same rule slots. Segments stay out of the datafile, qualification typically lands under thirty seconds, and Optimizely&#39;s own docs concede that when you need 100% accuracy you should fall back to plain custom attributes. Different source, same predicate machinery. That&#39;s the whole story here. This demo has no ODP account on purpose.&lt;/p&gt;
&lt;h2&gt;From prose to proof: the day the headless head went dark&lt;/h2&gt;
&lt;p&gt;Here is the embarrassing measurement first, because it earns the rest of the section. The moment the A/B rule&#39;s audience changed from &lt;em&gt;Everyone&lt;/em&gt; to &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Non-mobile visitors&lt;/span&gt;, the Next.js head, which part 2 proudly demonstrated holding a perfect 33/33/34, went one hundred percent dark:&lt;/p&gt;
&lt;pre style=&quot;background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;&quot;&gt;default (master)      300  (100.00 %)
FAILED: &quot;300 request(s) were served the master page (no FX decision) - check
that the rule is Running, Traffic Allocation is 100% and the SDK key matches
the rule&#39;s environment.&quot;&lt;/pre&gt;
&lt;p&gt;Rule running, allocation 100%, SDK key correct. The test&#39;s own diagnostic, written by me in part 2, blames everything except the actual cause: the head&#39;s &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;decide()&lt;/span&gt; sent &lt;strong&gt;no attributes&lt;/strong&gt;, so the visitor matched neither audience and fell through to everyone-else-off. No errors, green dashboards, a misleading failure message, and a head silently serving master to every visitor from the minute the audience shipped. Audiences don&#39;t add a feature to your architecture. They add a &lt;em&gt;requirement&lt;/em&gt; to every head you run.&lt;/p&gt;
&lt;p&gt;The fix is a mirror: a &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;buildAttributes()&lt;/span&gt; on the Next side that reproduces the .NET pipeline byte for byte. The parity contract lives in one file with a comment that says exactly that: if the heuristics drift, parity dies at the attribute layer while everyone debugs the hash. The contract itself fits in a table, which is how it should be reviewed:&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;Attribute&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;The rule both heads obey&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;device&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Substring tests, case-insensitive: iPad/Tablet &amp;rarr; &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;tablet&lt;/span&gt;, else Mobi &amp;rarr; &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;mobile&lt;/span&gt;, else &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;desktop&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;location&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Header path is strict: &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;^[A-Z]{2}$&lt;/span&gt; and &amp;ne; &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;XX&lt;/span&gt;, &lt;strong&gt;no case folding&lt;/strong&gt; (mirrors the measured CMS resolver); the Accept-Language fallback &lt;em&gt;does&lt;/em&gt; fold case (&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;pl-PL&lt;/span&gt; &amp;rarr; &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;PL&lt;/span&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;logged_in&lt;/span&gt;, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;cms_role&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Real types, truthful constants on the auth-less head (&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;false&lt;/span&gt;, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;&quot;none&quot;&lt;/span&gt;): absent and false are different inputs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;consent&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;bool.TryParse&lt;/span&gt; semantics: case-insensitive &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;true&lt;/span&gt;, anything else false&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Upgrading the JS SDK to v6 along the way produced one trap worth its own line. v6 is explicit-opt-in across the board: polling config manager, logger, and ODP all opt-in. So is the &lt;strong&gt;event processor&lt;/strong&gt;: omit &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;createBatchEventProcessor()&lt;/span&gt; and decisions keep flowing while the SDK dispatches &lt;em&gt;no events at all&lt;/em&gt;. Served-but-unmeasured, the exact failure shape this series keeps finding, now available at the initialization layer. (The same modularity quietly fixed an old log nag: no ODP manager, no &amp;ldquo;ODP is not integrated&amp;rdquo; warnings.)&lt;/p&gt;
&lt;p&gt;Two Next.js-specific notes earned their scars. The SDK instance is cached on &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;globalThis&lt;/span&gt;, the same trick Prisma clients use, because &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;next dev&lt;/span&gt;&#39;s Fast Refresh re-evaluates modules. A module-level singleton would leak a fresh polling manager and event processor on every save, and your datafile CDN would meet them all.&lt;/p&gt;
&lt;p&gt;And one asymmetry to disclose rather than hide: the C# head flushes its event queue on shutdown because the DI container disposes the client (part 2&#39;s contract). A Node process has no such container. The batch rides &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;flushInterval&lt;/span&gt;, and what happens at the end of a process&#39;s life depends entirely on what kind of life it had:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Long-running Node&lt;/strong&gt; (&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;next start&lt;/span&gt;, a container): safe. The queue drains on the interval, and a tail lost to a SIGTERM is a rounding error.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Serverless / edge&lt;/strong&gt;: risky. The platform can freeze the instance the moment the response returns, and a consented conversion dies in the buffer as a zombie event.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The fix where it matters&lt;/strong&gt;: shrink the batch to the point of synchronicity, or &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;await client.close()&lt;/span&gt; before the invocation ends, and accept the latency as the price of the data.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Pretending the queue always drains would be exactly the kind of silence this series hunts.&lt;/p&gt;
&lt;figure style=&quot;margin: 24px auto; text-align: center; max-width: 800px;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-3/next-head.png&quot; alt=&quot;The Next.js headless head on a mobile viewport serving variant_a via mobile_delivery.&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;&lt;em&gt;The Next.js headless head on a mobile viewport: the same FX decision serves &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_a&lt;/span&gt; via &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;mobile_delivery&lt;/span&gt;, and the variation&#39;s title arrives through Optimizely Graph, with zero coordination with the MVC head.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Then the green numbers, all in one evening:&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;Check&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Mobile, 60 fresh visitors (hard assertion: a delivery rolls no dice)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;60/60 &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_a&lt;/span&gt; via &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;mobile_delivery&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Desktop distribution, n = 300, against the Next head&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;103 / 105 / 92, &lt;strong&gt;&amp;chi;&amp;sup2; = 0.985&lt;/strong&gt; (critical 13.816 at &amp;alpha; = 0.001, df = 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Same regression against the MVC head&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;99 / 109 / 92, &amp;chi;&amp;sup2; = 1.465&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Cross-head parity: 4 fixed + 6 random visitors &amp;times; desktop and mobile&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;strong&gt;arm AND rule identical on both heads, 20/20 pairs&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;figure style=&quot;margin: 24px auto; text-align: center; max-width: 800px;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-3/next-distribution.svg&quot; alt=&quot;The desktop chi-square run against the headless Next.js head: 103 / 105 / 92 over n = 300, chi-square 0.985.&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;&lt;em&gt;The desktop chi-square run against the headless Next.js head: 103 / 105 / 92 over n = 300, &amp;chi;&amp;sup2; = 0.985, well under the 13.816 critical value. Every decision came from Optimizely FX in the Node SDK, zero coordination with the MVC head.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The parity test asserts the rule key, not just the arm. &amp;ldquo;mobile got &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_a&lt;/span&gt;&amp;rdquo; and &amp;ldquo;mobile happened to be bucketed into &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_a&lt;/span&gt;&amp;rdquo; are different claims, and only &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;data-rule&lt;/span&gt; in the markup separates them. MurmurHash plus deterministic audience evaluation. Still zero coordination code.&lt;/p&gt;
&lt;p&gt;The arithmetic deserves to be shown once, not just asserted. For the MVC regression (99 / 109 / 92 over n = 300, expected &amp;asymp; 100 per arm):&lt;/p&gt;
&lt;pre style=&quot;background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;&quot;&gt;&amp;chi;&amp;sup2; = (99&amp;minus;100)&amp;sup2;/100 + (109&amp;minus;100)&amp;sup2;/100 + (92&amp;minus;100)&amp;sup2;/100
   = 0.01 + 0.81 + 0.64 &amp;asymp; 1.46&lt;/pre&gt;
&lt;p&gt;(The suite reports 1.465 because it tests against the configured 33.33/33.33/33.34 rather than exact thirds.) With df = k &amp;minus; 1 = 2 and &amp;alpha; = 0.001, the critical value is 13.816, and 1.465 ≪ 13.816, so the null hypothesis (&amp;ldquo;the split matches the configuration&amp;rdquo;) survives comfortably. In words: after carving the mobile segment out with an audience, the hash shows no distributional anomaly in what remains. The same computation on the Next head&#39;s 103 / 105 / 92 gives 0.985, comfortably inside the spread a fair split throws at n = 300.&lt;/p&gt;
&lt;p&gt;Threats to validity, disclosed as ever: the chi-square run verifies the bucketing distribution, not content delivery (part 2&#39;s caveat stands). The mobile check is exhaustive rather than statistical, because a 100% delivery is deterministic: a single counterexample falsifies it, no &amp;alpha; required. And every test here runs without a consent cookie, which after the egress gate means &lt;strong&gt;the suite sends zero impressions to FX&lt;/strong&gt;. Three hundred requests of load testing used to pollute Results. Now the same suite is invisible to it. That started as a privacy control and turned out to be test hygiene.&lt;/p&gt;
&lt;h2&gt;QA: three override levels, three owners&lt;/h2&gt;
&lt;p&gt;&amp;ldquo;How does QA see the variant for a segment they don&#39;t belong to?&amp;rdquo; That is the question audiences force on every test plan. Three answers, escalating by who owns them:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;New visitor&lt;/strong&gt; (the cookie-reset button, part 2): owned by anyone with a browser. Re-rolls the dice but can&#39;t cross an audience boundary. The button also demonstrates something subtler: rotating the visitor ID re-buckets every &lt;em&gt;experiment&lt;/em&gt;, leaves a &lt;em&gt;delivery&lt;/em&gt; unmoved (its audience is deterministic in the attributes, not the ID), and the CMS Audience doesn&#39;t even notice; its criteria never saw your visitor ID in the first place. One page, three different notions of who &amp;ldquo;you&amp;rdquo; are. QA plans that conflate them chase ghosts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Allowlist&lt;/strong&gt; (dashboard, per rule, up to fifty IDs): owned by the marketer, pins a visitor ID to an arm. Measured here: an allowlisted ID came back &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_b&lt;/span&gt; with the SDK reason &amp;ldquo;is forced in variation&amp;rdquo;. Caveat: allowlisted traffic still fires impressions, so your QA session pollutes Results.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;?fx_force=demo_ab_test:variant_b&lt;/span&gt;&lt;/strong&gt; (this part): owned by the developer, a Development-only query parameter mapped to the SDK&#39;s forced-decision API. It bypasses audiences &lt;em&gt;and&lt;/em&gt; allocation (forced mobile, forced desktop, doesn&#39;t matter), and, deliberately unlike the allowlist, a forced request also suppresses its decision events. QA that leaves no fingerprints on the data it&#39;s there to protect.&lt;/li&gt;
&lt;/ol&gt;
&lt;figure style=&quot;margin: 24px auto; text-align: center; max-width: 800px;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-3/ab-rule-allowlist.png&quot; alt=&quot;The A/B Test rule: audience Non-mobile visitors, 100%, the split, and the allowlist pinning a QA visitor ID to variant_b.&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;&lt;em&gt;The A/B Test rule: audience Non-mobile visitors, 100%, the 33.33 / 33.33 / 33.34 split, and the allowlist pinning a QA visitor ID to &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_b&lt;/span&gt;.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The whole mechanism is one small source behind one small interface, so the SDK types stay where part 2 put them:&lt;/p&gt;
&lt;pre style=&quot;background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;&quot;&gt;var forced = forcedDecisionSource.GetForcedDecisions();   // [] outside Development
foreach (var (flagKey, variationKey) in forced)
{
    userContext.SetForcedDecision(
        new OptimizelyDecisionContext(flagKey),
        new OptimizelyForcedDecision(variationKey));
}
_eventsAllowed &amp;amp;= forced.Count == 0;   // QA traffic never reaches Results&lt;/pre&gt;
&lt;p&gt;While we&#39;re holding the decide call open, the full option set gets a reference table (part 2 named these in prose):&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;Decide option&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;What it does&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;Where this series uses it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;INCLUDE_REASONS&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Returns the evaluation trace: which rule matched, why audiences failed&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Every call; feeds the diagnostics panel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;DISABLE_DECISION_EVENT&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Evaluates without sending an impression&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;No-consent requests; forced QA requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;ENABLED_FLAGS_ONLY&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Filters &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;DecideAll&lt;/span&gt; to enabled flags&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;IGNORE_USER_PROFILE_SERVICE&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Skips UPS stickiness for this call&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;n/a (this demo runs without UPS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;EXCLUDE_VARIABLES&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Omits variable payloads for cheaper decisions&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The CMS side has its own counterpart, &lt;strong&gt;View as Audience&lt;/strong&gt; in the editor, with one boundary note: it previews visitor groups, not FX arms, and the docs scope it to CMS-rendered sites only. Pair the two in your test plan. Neither substitutes for the other.&lt;/p&gt;
&lt;p&gt;Forced decisions don&#39;t persist. They clear with the user context, which in this architecture means they last exactly one request. That&#39;s not a limitation. Per-request is the only scope that can&#39;t leak into someone&#39;s real session.&lt;/p&gt;
&lt;h2&gt;Runbook: &amp;ldquo;the page always serves master&amp;rdquo;, audience edition&lt;/h2&gt;
&lt;p&gt;Part 2 ended with a checklist for the all-master symptom. Audiences roughly double it. The developer&#39;s mental map first: &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;data-rule&lt;/span&gt; on the pill is the fork in the road.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;data-rule is empty&lt;/strong&gt;, no decision happened at all: SDK key, datafile or flag off (part 2&#39;s checklist).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data-rule = default-rollout-&amp;hellip;&lt;/strong&gt;, &amp;ldquo;everyone else&amp;rdquo; fired, so no audience matched:
&lt;ul&gt;
&lt;li&gt;Attributes row wrong or missing &amp;rarr; context pipeline gap (the dark-head failure: a head sending nothing).&lt;/li&gt;
&lt;li&gt;Attributes row correct &amp;rarr; condition-side defect (whitespace, case, type); open the audience in Code Mode, where quotes make it visible.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data-rule = a real rule, arm = original&lt;/strong&gt;, working as designed: the control arm serves master via fallback.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And the full ordered checklist, where each step assumes the previous ones passed:&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;#&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;Check&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;Where the truth shows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;SDK key matches the rule&#39;s environment; ruleset Running; variation toggles ON&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Part 2&#39;s checklist (dashboard)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;The decision reached a rule at all&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;data-rule&lt;/span&gt; on the pill: &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;default-rollout-&amp;hellip;&lt;/span&gt; means everyone-else fired&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;3&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;The attributes you think you send are the attributes you send&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Diagnostics panel, Attributes row, wrong name, wrong case, wrong type, missing key are all visible here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;4&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;The audience condition matches those attributes byte-for-byte&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Reasons: &amp;ldquo;collectively evaluated to FALSE&amp;rdquo; while the panel shows the right value means a condition-side defect; open the audience in Code Mode, quotes make it visible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;5&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Type mismatches&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;SDK log (not reasons): &amp;ldquo;evaluated to UNKNOWN because a value of type &amp;hellip; was passed&amp;rdquo;, requires the part-2 logger&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;6&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;The head sends attributes at all&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;The dark-head failure: &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;decide()&lt;/span&gt; without attributes matches nothing, and the test diagnostics will blame the rule&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;7&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Datafile freshness&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Audience edits propagate on the polling interval like every other change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;8&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;(CMS side) the personalized block&#39;s &lt;em&gt;layer&lt;/em&gt; is reachable&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;A block personalized in a version the visitor never receives is invisible by construction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;9&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;(CMS side) the rendering layer is registered&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Audiences screen working &amp;ne; filter running, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;AddVisitorGroupsMvc()&lt;/span&gt;, then the startup self-check proves it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Sharp edges, part 3: ranked by blood actually drawn&lt;/h2&gt;
&lt;p&gt;Same tradition, same ordering rule. Every one of these happened on this instance, this week.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. A trailing space in an audience condition.&lt;/strong&gt; The audience said &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;device = &quot;mobile &quot;&lt;/span&gt;. The builder keeps whatever the clipboard delivered, and exact-match fields are not trimmed. Exact match means exact: every mobile visitor silently fell through two rules to master while every dashboard stayed green. The only witness was the diagnostics panel: attributes showed &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;device=mobile&lt;/span&gt;, reasons showed &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Audiences for rule &quot;mobile_delivery&quot; collectively evaluated to FALSE&lt;/span&gt;, and the contradiction between those two lines &lt;em&gt;is&lt;/em&gt; the diagnosis. Code Mode confirms it fastest. JSON quotes make whitespace visible. Cost: the better part of an evening, and it produced this list&#39;s ordering.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. One &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;[OutputCache]&lt;/span&gt; attribute defeats both engines and privacy at once.&lt;/strong&gt; Swap the demo&#39;s &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;no-store&lt;/span&gt; guard for platform output caching, and the measurement reads like an incident report: a desktop visitor received the &lt;em&gt;mobile&lt;/em&gt; visitor&#39;s page from cache, wrong arm, wrong personalization, and the other person&#39;s visitor ID rendered in the panel, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Age: 0&lt;/span&gt; confirming the hit. The CMS-aware output cache has been gone since the CMS 12 rewrite, and CMS 13 still gives the platform layer nothing that knows your pages are personal (verified: no output-cache type exists anywhere in the 13.1.0 assemblies). The fix is not a smarter cache key. It is classifying pages: personalized means &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;no-store&lt;/span&gt;, full stop. (The CDN analog is &amp;ldquo;ignore cookies in the cache key&amp;rdquo;, which produces the same leak at planetary scale.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Type mismatch evaluates to UNKNOWN, and only the logger says so.&lt;/strong&gt; Send &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;device&lt;/span&gt; as a boolean and &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;INCLUDE_REASONS&lt;/span&gt; reports a flat &amp;ldquo;collectively evaluated to FALSE&amp;rdquo;. The &lt;em&gt;why&lt;/em&gt; lives one layer down, in the SDK log the part-2 adapter made visible: &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Audience condition {&quot;match&quot;:&quot;exact&quot;,&quot;name&quot;:&quot;device&quot;,&quot;value&quot;:&quot;mobile&quot;} evaluated to UNKNOWN because a value of type &quot;Boolean&quot; was passed&lt;/span&gt;. Wire the logger on day one was part 2&#39;s advice. This is the day it pays. (Note the &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;&quot;match&quot;&lt;/span&gt; spelling: the datafile serialization, not the dashboard&#39;s &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;match_type&lt;/span&gt;.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. A missing attribute is quieter than a wrong one.&lt;/strong&gt; Drop &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;device&lt;/span&gt; from the dictionary and the fall-through looks identical. But this time there is no WARN anywhere, and the SDK mentions missing attributes only at debug verbosity. Absent isn&#39;t false, and absent doesn&#39;t log. The gradient of silence: wrong type warns (if you wired logs), wrong name says nothing, missing says nothing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. Case sensitivity, now in three places.&lt;/strong&gt; Part 2 had the case-sensitive &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;VariationKey&lt;/span&gt;. Part 3 adds attribute &lt;em&gt;names&lt;/em&gt; (&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Device&lt;/span&gt; &amp;ne; &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;device&lt;/span&gt;), attribute &lt;em&gt;values&lt;/em&gt; (&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Mobile&lt;/span&gt; &amp;ne; &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;mobile&lt;/span&gt;; our custom criterion deliberately Trim+lowercases editor input as a courtesy FX won&#39;t extend), and the geo header&#39;s &lt;em&gt;value&lt;/em&gt; (&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;pl&lt;/span&gt; resolves to nothing). That last one is also the closing argument for the unification: part 2&#39;s hand-rolled parser folded case and would have accepted &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;pl&lt;/span&gt;. The native resolver doesn&#39;t. Two readers of one header with different tolerances is exactly the divergence class that breaks cross-engine and cross-head consistency: one resolver on the CMS, one written-down contract for the second head.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;6. A personalized item doesn&#39;t leak to Graph: it ceases to exist.&lt;/strong&gt; The coexistence section holds the measurement. The dangerous half is that nothing tells you: the sync succeeds, the schema is fine, the array is just empty. If your roadmap says &amp;ldquo;headless next year&amp;rdquo;, every CMS Audience you ship today is a block that will silently vanish in the migration.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;7. One header steers two engines.&lt;/strong&gt; &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;AddCmsClientGeolocation&lt;/span&gt; is genuinely elegant. And it concentrates trust in a single header that, exposed without an edge to strip client values, both engines will believe. The part-2 caveat, squared.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;8. The datafile is still the clock.&lt;/strong&gt; Editing an audience is a datafile change like any other. This instance picked the fix from edge #1 up in about 75 seconds on the default polling. Not a repeat of part 2&#39;s lifecycle section. Just a reminder that audience edits ride the same train, and so does your kill switch.&lt;/p&gt;
&lt;h2&gt;FAQ, the questions as people actually type them&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Can I use Visitor Groups / CMS Audiences with a headless frontend?&lt;/strong&gt; No. Evaluation needs the CMS runtime, and the measurement here is blunter than the docs: a personalized content-area item isn&#39;t filtered for Graph consumers. It is &lt;em&gt;absent from the index entirely&lt;/em&gt;. Personalize whole page versions with FX Audiences instead. They evaluate in any SDK.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Targeted delivery or A/B test: which one do I want?&lt;/strong&gt; Deliveries deploy, experiments measure. A delivery produces no Results page at all, so the question is really &amp;ldquo;do I need to learn anything from this traffic?&amp;rdquo; If yes, experiment; if you already know and just want control and ramp, delivery.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why does my FX audience never match?&lt;/strong&gt; In observed order of likelihood: a stray character in the condition value, the wrong value case, the wrong attribute-name case, the wrong value &lt;em&gt;type&lt;/em&gt;, the attribute not sent at all. The runbook above walks the diagnostics. The short version: the panel shows what you sent, and the SDK log shows what the evaluator thought of it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can a delivery run above an experiment?&lt;/strong&gt; No. The ruleset evaluates experiments first, and the UI encodes that as fixed sections rather than a sortable list. Carve segments out of an experiment with audiences, not with rule order.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Do I need ODP to use audiences?&lt;/strong&gt; No. Everything in this article runs on request-derived attributes. ODP (Real-Time Segments) is the path when targeting needs &lt;em&gt;customer data&lt;/em&gt; (past purchases, lifecycle stage) rather than facts about the current request.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How fast is the kill switch?&lt;/strong&gt; One datafile propagation. This instance picked up an audience edit in ~75 seconds on default polling. The guarantee is your polling interval (or webhook latency), not the dashboard click.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Does QA and test traffic pollute my results?&lt;/strong&gt; Dashboard allowlists do: they fire impressions like any decision. This build&#39;s &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;?fx_force&lt;/span&gt; deliberately doesn&#39;t, and the consent gate has a pleasant side effect: the entire automated suite (hundreds of requests per run) sends zero events, because test traffic never carries a consent cookie.&lt;/p&gt;
&lt;h2&gt;The take&lt;/h2&gt;
&lt;p&gt;The model that survives all of the above is short enough to memorize. The CMS owns &lt;strong&gt;what&lt;/strong&gt;: versioned, publishable content variations, plus fragment-level personalization wherever the CMS itself renders. FX rules own &lt;strong&gt;who and whether&lt;/strong&gt;: deterministic assignment, deliveries for rollout, experiments for measurement. Audiences, both kinds, own &lt;strong&gt;for whom&lt;/strong&gt;, and the kind you pick decides where the personalization can exist at all. CMS Audiences see everything and travel nowhere. FX Audiences see only what you send and run everywhere you do.&lt;/p&gt;
&lt;p&gt;For the business reader who skipped to the end: the mobile personalization in this demo shipped without a deploy, measured itself before an audience of three hundred synthetic visitors, survived a re-platform to a second rendering stack with zero integration code, and can be killed from a dashboard in about the time it takes to refresh a datafile. The block-level personalization shipped too. And it stopped at the CMS&#39;s edge, invisible to the headless head, unmeasured by anything but page-view counters. Both behaviors are by design. The architecture decision is choosing which design your roadmap can live with.&lt;/p&gt;
&lt;p&gt;The fragile parts haven&#39;t changed character since part 2: every failure in this article degraded to &amp;ldquo;someone quietly sees master&amp;rdquo; or &amp;ldquo;someone quietly sees too much&amp;rdquo;, and not one of them threw. The integration is still strings and dictionaries. The engineering is still making the silence loud: a diagnostics panel, an SDK logger, a startup self-check, and tests that assert the rule, not just the arm.&lt;/p&gt;
&lt;p&gt;Quiet hero, part 3: now it knows who you are. Still quiet, that&#39;s still the problem to engineer around.&lt;/p&gt;
&lt;h2&gt;Glossary&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;Term&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;In one sentence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Goodness_of_fit&quot;&gt;Chi-square goodness-of-fit test&lt;/a&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Tests whether observed arm counts are consistent with the configured split; &amp;chi;&amp;sup2; = &amp;Sigma; (observed &amp;minus; expected)&amp;sup2; / expected.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Degrees_of_freedom_(statistics)&quot;&gt;Degrees of freedom&lt;/a&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Category counts free to vary; k arms give df = k &amp;minus; 1, so our three arms test at df = 2.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Statistical_significance&quot;&gt;Significance level &amp;alpha;&lt;/a&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;The false-alarm rate the test tolerates; this series uses &amp;alpha; = 0.001 so a healthy split almost never red-flags live.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Critical_value_(statistics)&quot;&gt;Critical value&lt;/a&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;The rejection threshold for the statistic, 13.816 for df = 2 at &amp;alpha; = 0.001.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Standard_error&quot;&gt;Sampling error&lt;/a&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Per-arm share fluctuates by &amp;asymp; &amp;radic;(p(1&amp;minus;p)/n), about 2.7 percentage points at n = 300, p = ⅓.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/MurmurHash&quot;&gt;MurmurHash&lt;/a&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;The deterministic hash every FX SDK applies to visitor ID + experiment; the reason parity needs no coordination.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Pure_function&quot;&gt;Pure function&lt;/a&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Same input, same output, no side effects, FX audience evaluation in one phrase.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Predicate_(mathematical_logic)&quot;&gt;Predicate&lt;/a&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;A boolean-valued function; both audience kinds are predicates over different inputs.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Eventual_consistency&quot;&gt;Eventual consistency&lt;/a&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Distributed state converges after a delay, the datafile (~75 s observed) and the Graph index both live here.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/feature-experimentation/docs/run-flag-deliveries&quot;&gt;Targeted delivery&lt;/a&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;A rule serving one variation to an audience; deployment, not measurement, no Results page.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/feature-experimentation/docs/manage-config-datafile&quot;&gt;Datafile&lt;/a&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Configuration-as-data: the JSON snapshot of flags, rules and audiences every SDK evaluates locally.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;a href=&quot;https://samnewman.io/patterns/architectural/bff/&quot;&gt;Backend for Frontend&lt;/a&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;The server-side layer that keeps SDK and Graph keys out of the browser on the headless head.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Defense_in_depth_(computing)&quot;&gt;Defense in depth&lt;/a&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Layered safeguards: consent gate, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;no-store&lt;/span&gt;, antiforgery, origin checks, none trusted alone.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/feature-experimentation/docs/run-a-b-tests&quot;&gt;Allowlist&lt;/a&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;A per-rule list (up to fifty user IDs) pinning specific visitors to specific arms; fires impressions like normal decisions.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/feature-experimentation/docs/forced-decision-methods-csharp&quot;&gt;Forced decision&lt;/a&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;An SDK-level override bypassing audiences and allocation; not persistent, it lives and dies with the user context.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Further reading&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v13.0.0-CMS/docs/audiences&quot;&gt;Create audiences (CMS 13)&lt;/a&gt; &amp;middot; &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v13.0.0-CMS/docs/personalize-a-digital-experience-with-audiences&quot;&gt;Personalize content with audiences&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v13.0.0-CMS/docs/breaking-changes-in-cms-13&quot;&gt;CMS 13 breaking changes&lt;/a&gt;, the &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;AddVisitorGroupsMvc().AddVisitorGroupsUI()&lt;/span&gt; registration &amp;middot; &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v13.0.0-CMS/docs/install-cms13&quot;&gt;Install CMS 13&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/feature-experimentation/docs/interactions-between-flag-rules&quot;&gt;Interactions between flag rules&lt;/a&gt;, experiments before deliveries; roll-down vs jump&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/feature-experimentation/docs/run-flag-deliveries&quot;&gt;Run flag deliveries&lt;/a&gt; &amp;middot; &lt;a href=&quot;https://support.optimizely.com/hc/en-us/articles/38816521665933&quot;&gt;Define attributes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/feature-experimentation/docs/forced-decision-methods-csharp&quot;&gt;Forced decision methods (C#)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/feature-experimentation/docs/upgrade-the-javascript-sdk-from-v5-to-v6&quot;&gt;Upgrade the JavaScript SDK from v5 to v6&lt;/a&gt;, the explicit &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;eventProcessor&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/feature-experimentation/docs/real-time-audiences-for-feature-experimentation&quot;&gt;Real-Time Segments for Feature Experimentation&lt;/a&gt;, the ODP path&lt;/li&gt;
&lt;li&gt;Part 1: &lt;a href=&quot;https://pino-labs.com/blog/content-variations-cms-13-quiet-hero/&quot;&gt;Content Variations in CMS 13, the quiet hero&lt;/a&gt; &amp;middot; Part 2: &lt;a href=&quot;/link/06d8f538fc754cb9b4362f2b31071f31.aspx&quot;&gt;Unlock Experimentation with Content Variations in CMS 13&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>            <guid>https://world.optimizely.com/blogs/piotr-nowak---optimizely--azure/dates/2026/6/content-variations-in-cms-13-part-3-audiences-vs-audiences/</guid>            <pubDate>Sun, 14 Jun 2026 17:46:48 GMT</pubDate>           <category>Blog post</category></item><item> <title>Unlock Experimentation with Content Variations in CMS 13</title>            <link>https://world.optimizely.com/blogs/piotr-nowak---optimizely--azure/dates/2026/6/unlock-experimentation-with-content-variations-in-cms-13/</link>            <description>&lt;p&gt;&lt;em&gt;Part 1 argued that Content Variations is the CMS 13 feature that didn&#39;t get the keynote but should have. This is the follow-up: wiring those variations to Optimizely Feature Experimentation, measuring whether the traffic split actually holds, and serving the same experiment from two heads (classic MVC and headless Next.js) without the experiment noticing. Everything below ran on a real CMS 13.0.2 instance with FX SDK 4.3.0; the numbers are from those runs, not from a slide.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Part 1 ended with variations sitting in the CMS, published, versioned, queryable. Nice. Also useless, commercially, until something decides &lt;em&gt;who sees which arm&lt;/em&gt; and &lt;em&gt;whether it moved a number&lt;/em&gt;. The CMS deliberately has no opinion about that. There is no traffic-splitting UI anywhere near the Variations dropdown, and the absence is a design decision, not a gap. Delivery belongs to an experimentation engine.&lt;/p&gt;
&lt;p&gt;The entire integration between the two products is one string.&lt;/p&gt;
&lt;div style=&quot;display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 12px 16px; margin: 24px 0; padding: 22px 20px; background: #f5f5f4; border: 1px solid #e7e5e4; border-radius: 6px;&quot;&gt;&lt;span style=&quot;background: #ffffff; border: 1px solid #d6d3d1; color: #0c0a09; font-family: Consolas,Monaco,monospace; font-size: 14px; padding: 8px 14px; border-radius: 6px;&quot;&gt;FX VariationKey&lt;/span&gt; &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 17px; font-weight: 600; color: #b85c38;&quot;&gt;=&lt;/span&gt; &lt;span style=&quot;background: #ffffff; border: 1px solid #d6d3d1; color: #0c0a09; font-family: Consolas,Monaco,monospace; font-size: 14px; padding: 8px 14px; border-radius: 6px;&quot;&gt;CMS Content Variation name&lt;/span&gt;&lt;/div&gt;
&lt;p&gt;That&#39;s the entire contract. FX&#39;s &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Decide()&lt;/span&gt; returns a variation key; you load the CMS variation with the same name; if the names drift apart, nothing throws and visitors just quietly get the master page. Everything else in this article is plumbing around that one equality, and most of the sharp edges live exactly where you&#39;d expect: in what happens when the equality silently fails.&lt;/p&gt;
&lt;h2&gt;Who does what&lt;/h2&gt;
&lt;p&gt;Three roles, three tools. Settle the division of labor before the code, because it&#39;s the actual selling point:&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;Role&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;Tool&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;Job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Editor&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;CMS Variations dropdown&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Creates and publishes the content arms. Never sees the FX dashboard.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Marketer / analyst&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;FX dashboard&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Owns the rule: traffic split, ramp, pause, Results. Never touches the CMS.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Developer&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;This article&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Wires the plumbing. Once.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;After the wiring ships, the next experiment needs no developer at all: an editor publishes new arms, a marketer points a rule at them, and the string contract does the rest. Stopping a test is one click in the dashboard, no deploy, no content rollback - the arms stay in the CMS, versioned, ready for the next round. If you have to sell this internally, that&#39;s the sentence to take into the budget meeting.&lt;/p&gt;
&lt;h2&gt;The shape of the experiment&lt;/h2&gt;
&lt;p&gt;One flag, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;demo_ab_test&lt;/span&gt;. Three FX variations: &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;original&lt;/span&gt;, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_a&lt;/span&gt;, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_b&lt;/span&gt;. Two CMS Content Variations: &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_a&lt;/span&gt; and &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_b&lt;/span&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Two&lt;/em&gt;, not three. The control arm gets &lt;strong&gt;no CMS variation&lt;/strong&gt;. When FX returns &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;original&lt;/span&gt;, the loader finds no variation by that name and falls back to the master page. The master &lt;em&gt;is&lt;/em&gt; the control. Creating a third CMS variation called &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;original&lt;/span&gt; is the first mistake I see teams make: it duplicates the master&#39;s content, and the two copies drift apart the first time an editor fixes a typo in one of them. Your control arm is then no longer your canonical page, and every lift number you report afterwards is quietly wrong. Let the fallback do its job.&lt;/p&gt;
&lt;p&gt;From the editor&#39;s chair, the whole feature is part 1&#39;s workflow with one naming rule attached: Variations dropdown, &lt;em&gt;New Variation&lt;/em&gt;, name it &lt;strong&gt;exactly&lt;/strong&gt; &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_a&lt;/span&gt; - the key is case-sensitive, and &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Variant_A&lt;/span&gt; buys you the silent-master failure described below - then change the content and publish. Each arm publishes independently, so an unpublished draft of &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_b&lt;/span&gt; simply means that arm&#39;s visitors keep seeing master until it ships. No FX login, no flag keys, no deploys. The naming rule is the only place an editor can break the experiment, which makes it the one thing worth putting in their runbook.&lt;/p&gt;
&lt;figure style=&quot;margin: 24px 0; text-align: center;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-2/flags.png&quot; alt=&quot;Flag list in the FX dashboard&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;The &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;demo_ab_test&lt;/span&gt; flag in the FX dashboard.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The variation keys are defined on the flag, and each arm has its own flag on/off toggle:&lt;/p&gt;
&lt;figure style=&quot;margin: 24px 0; text-align: center;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-2/variations.png&quot; alt=&quot;Variation keys on the flag&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;Variation keys on the flag - these names must match the CMS variation names exactly.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;That per-variation toggle is the second quiet trap. The serving code treats &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Enabled == false&lt;/span&gt; as &quot;no decision, serve master.&quot; If &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_a&lt;/span&gt; has its toggle off, a third of your traffic is bucketed into an arm that renders the control while the dashboard reports them as exposed to the variant. All three arms stay &lt;strong&gt;on&lt;/strong&gt;; the &lt;em&gt;rule&lt;/em&gt;, not the variation toggle, is your kill switch.&lt;/p&gt;
&lt;p&gt;An A/B Test rule won&#39;t save without a metric, so an event has to exist first, even if you wire conversions later:&lt;/p&gt;
&lt;figure style=&quot;margin: 24px 0; text-align: center;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-2/events.png&quot; alt=&quot;Creating the conversion event&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;The conversion event has to exist before the rule will save.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Then the rule itself: audience &lt;em&gt;Everyone&lt;/em&gt;, traffic allocation 100%, distribution mode Manual at 33.33/33.33/33.34, baseline &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;original&lt;/span&gt;:&lt;/p&gt;
&lt;figure style=&quot;margin: 24px 0; text-align: center;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-2/rule.png&quot; alt=&quot;The A/B Test rule with manual distribution&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;The A/B Test rule: Everyone, 100% allocation, manual 33.33/33.33/33.34 distribution.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Most write-ups skip one field on the ruleset entirely: &lt;strong&gt;&quot;Then, for everyone else.&quot;&lt;/strong&gt; It&#39;s the fallthrough for visitors who don&#39;t match the rule, either because of the audience or because they fall outside the traffic allocation. At Everyone/100% it never fires, but it documents intent for the day someone ramps the test down to 50%. Set it to &lt;strong&gt;Off&lt;/strong&gt;. Off means &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Enabled = false&lt;/span&gt; means master means control. Setting it to &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;original&lt;/span&gt; &lt;em&gt;works&lt;/em&gt; (the loader falls back identically), but those visitors get reported as if the experiment decided for them, which pollutes the boundary between &quot;in the test&quot; and &quot;not in the test.&quot; Off keeps the boundary honest.&lt;/p&gt;
&lt;figure style=&quot;margin: 24px 0; text-align: center;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-2/rulesets.png&quot; alt=&quot;Ruleset with the everyone-else fallthrough&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;The &quot;Then, for everyone else&quot; fallthrough - set it to Off to keep the in-test boundary honest.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2&gt;Serving it: one decision, one load, one fallback&lt;/h2&gt;
&lt;p&gt;The controller-side code is small, and it should be. Controllers depend on an SDK-agnostic abstraction, one &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Decide&lt;/span&gt; per flag, one user context per request:&lt;/p&gt;
&lt;pre style=&quot;background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;&quot;&gt;public IActionResult Index(DemoPage currentPage)
{
    var decision = _decisions.Decide(&quot;demo_ab_test&quot;);
    var variationKey = decision.Enabled ? decision.VariationKey : null;

    var pageToRender = string.IsNullOrEmpty(variationKey)
        ? currentPage
        : _loader.Load&amp;lt;DemoPage&amp;gt;(currentPage.ContentLink, variationKey) ?? currentPage;
    // ...
}&lt;/pre&gt;
&lt;p&gt;The loader is the same &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;VariationLoaderOption&lt;/span&gt; mechanism from part 1, wrapped with a fallback:&lt;/p&gt;
&lt;pre style=&quot;background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;&quot;&gt;var options = new LoaderOptions { VariationLoaderOption.With(variationKey) };
if (contentLoader.TryGet&amp;lt;T&amp;gt;(link, options, out var variant) &amp;amp;&amp;amp; variant is not null)
    return variant;
return contentLoader.TryGet&amp;lt;T&amp;gt;(link, out var master) ? master : null;&lt;/pre&gt;
&lt;h3&gt;The decision service: the only class that knows the SDK&lt;/h3&gt;
&lt;p&gt;&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;_decisions&lt;/span&gt; above is deliberately not the Optimizely client. It&#39;s a thin abstraction, and the SDK types live in exactly one implementation behind it. That&#39;s less about architectural piety than about two practical facts: controllers become trivially testable (fake a &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;FlagDecision&lt;/span&gt;, done), and when SDK 5.x changes a signature you edit one file.&lt;/p&gt;
&lt;p&gt;The implementation is where the docs&#39; most important serving-side rule lands: &lt;strong&gt;one user context per request, shared by every decision in that request&lt;/strong&gt;. The SDK&#39;s &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;OptimizelyUserContext&lt;/span&gt; carries the visitor ID and attributes; creating it once per request (the service is DI-scoped) means the banner flag, the experiment flag, and the conversion all agree on who the visitor is:&lt;/p&gt;
&lt;pre style=&quot;background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;&quot;&gt;public class FeatureDecisionService(
    OptimizelyClient optimizely,
    IVisitorIdProvider visitorIdProvider,
    IVisitorAttributesProvider attributesProvider) : IFeatureDecisionService
{
    private OptimizelyUserContext? _userContext;

    public FlagDecision Decide(string flagKey)
    {
        var userContext = EnsureUserContext();
        if (userContext is null)
        {
            return new FlagDecision(false, null, null,
                [&quot;User context could not be created - SDK client unusable (e.g. missing datafile).&quot;]);
        }

        var decision = userContext.Decide(flagKey, [OptimizelyDecideOption.INCLUDE_REASONS]);
        return new FlagDecision(
            decision.Enabled,
            string.IsNullOrEmpty(decision.VariationKey) ? null : decision.VariationKey,
            decision.Variables?.ToDictionary(),
            decision.Reasons);
    }

    public void Track(string eventKey) =&amp;gt; EnsureUserContext()?.TrackEvent(eventKey);

    private OptimizelyUserContext? EnsureUserContext()
        =&amp;gt; _userContext ??= optimizely.IsValid
            ? optimizely.CreateUserContext(visitorIdProvider.GetOrCreateVisitorId(), BuildAttributes())
            : null;
}&lt;/pre&gt;
&lt;p&gt;A few lines in there do more work than they look like. The &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;IsValid&lt;/span&gt; guard: an SDK client without a datafile still hands out user contexts whose every &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Decide&lt;/span&gt; is an error decision; checking validity up front turns &quot;mysteriously always master&quot; into an explicit fallback with a reason string. &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;INCLUDE_REASONS&lt;/span&gt; is one of five documented decide options (&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;DISABLE_DECISION_EVENT&lt;/span&gt;, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;ENABLED_FLAGS_ONLY&lt;/span&gt;, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;IGNORE_USER_PROFILE_SERVICE&lt;/span&gt;, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;EXCLUDE_VARIABLES&lt;/span&gt; are the others) and the only one this demo needs. &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;DISABLE_DECISION_EVENT&lt;/span&gt; is the other one I reach for in real projects: it asks for a flag&#39;s value without recording an impression, for the places where rendering logic needs the flag but the visitor shouldn&#39;t count as exposed. And &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;TrackEvent&lt;/span&gt; rides the &lt;em&gt;same&lt;/em&gt; context, which is the entire attribution story: no variation key travels with the conversion, ever. The stats engine joins exposure to conversion purely on the visitor ID.&lt;/p&gt;
&lt;p&gt;Audience attributes enter at context creation too. The demo derives them from the request instead of hardcoding values - device class from the User-Agent, country from CDN geo headers with an &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Accept-Language&lt;/span&gt; fallback:&lt;/p&gt;
&lt;pre style=&quot;background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;&quot;&gt;return new Dictionary&amp;lt;string, object?&amp;gt;
{
    [&quot;device&quot;]   = ResolveDevice(request),   // &quot;mobile&quot; | &quot;tablet&quot; | &quot;desktop&quot;
    [&quot;location&quot;] = ResolveLocation(request)  // &quot;PL&quot;, &quot;SE&quot;, ... or &quot;unknown&quot;
};&lt;/pre&gt;
&lt;p&gt;With those flowing, an FX Audience like &lt;em&gt;device = mobile&lt;/em&gt; targets a rule with zero code changes; the rule evaluates against whatever attributes the context carried. One honesty note the docs won&#39;t volunteer: geo headers like &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;CF-IPCountry&lt;/span&gt; are only trustworthy when &lt;strong&gt;your&lt;/strong&gt; edge injects them and strips client-supplied values. Exposed directly, they&#39;re attacker-supplied input, and anyone can self-select into your geo audience with a curl flag. Fine for a demo; gate it behind your CDN in production.&lt;/p&gt;
&lt;p&gt;Three failure modes funnel into the same outcome, and all three are &lt;em&gt;invisible&lt;/em&gt; in production:&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;What happened&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;What FX reports&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;What the visitor sees&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;Flag off / rule paused / no datafile&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;no decision&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;master&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;FX returned &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;original&lt;/span&gt; (control)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;exposed to &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;original&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;master - &lt;em&gt;by design&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;FX returned &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_a&lt;/span&gt; but the CMS variation was renamed or deleted&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;exposed to &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_a&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;master - &lt;strong&gt;silently wrong&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The third row is the one that should worry you. The experiment keeps collecting data, the dashboard keeps attributing conversions to &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_a&lt;/span&gt;, and every visitor in that arm is actually looking at the control. Nothing throws. No log line by default (more on the SDK&#39;s default logger below). The only place the truth surfaces is &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Decide&lt;/span&gt;&#39;s &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;INCLUDE_REASONS&lt;/span&gt; output, which is why the demo renders a development-only diagnostics panel with the visitor ID, the resolved attributes, and the SDK&#39;s own reasoning: which rule matched, how the visitor was bucketed, or why no decision was made. Build that panel before you need it. It converts &quot;the page always shows master and nobody knows why&quot; from an afternoon of debugging into a single glance.&lt;/p&gt;
&lt;p&gt;Bucketing stickiness rides on a first-party cookie (&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;CMS_VisitorId&lt;/span&gt;, one year, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;HttpOnly&lt;/span&gt;). One non-obvious attribute: &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;SameSite=None; Secure&lt;/span&gt;. The CMS editor previews pages in a cross-origin iframe, and without &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;SameSite=None&lt;/span&gt; the browser drops the cookie there. Every preview request then mints a new visitor and a possibly different arm, and your editors file a bug that &quot;the page keeps flickering between variants.&quot; The experiment is fine; the cookie policy isn&#39;t.&lt;/p&gt;
&lt;p&gt;Two server hygiene points that the demo enforces and most tutorials skip: bucketed responses carry &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Cache-Control: no-store&lt;/span&gt; (a shared cache serving one visitor&#39;s arm to everyone collapses the split), and this head&#39;s conversion endpoint validates an antiforgery token - a need specific to the MVC head, because its &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;SameSite=None&lt;/span&gt; cookie rides along on cross-site POSTs. An experimentation demo whose results can be inflated by a hidden form on someone else&#39;s page is not a demo you want to give. The headless head solves the same problem differently; details where they belong, in the Next.js section.&lt;/p&gt;
&lt;h2&gt;From prose to proof, again: does 33/33/34 actually hold?&lt;/h2&gt;
&lt;p&gt;Part 1 measured storage and cache behavior. The follow-up question here is more basic and more embarrassing if you skip it: &lt;em&gt;does the traffic actually split the way the rule says?&lt;/em&gt; &quot;Trust the hash function&quot; is a fine answer until you&#39;re presenting and someone asks how you know.&lt;/p&gt;
&lt;p&gt;The check is an xUnit test that fires N independent requests at the running page &lt;strong&gt;without cookies&lt;/strong&gt; (the server mints a fresh visitor ID per request, so every request is a fresh bucketing), parses which arm each response declares, and runs a chi-square goodness-of-fit test against 33.33/33.33/33.34.&lt;/p&gt;
&lt;p&gt;Three runs at n = 300 against the MVC head:&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;Run&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;original&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;variant_a&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;variant_b&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;&quot;&gt;&amp;chi;&amp;sup2; (df = 2)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;90 (30.0%)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;106 (35.3%)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;104 (34.7%)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;1.518&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;104 (34.7%)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;82 (27.3%)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;114 (38.0%)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;5.352&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;3&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;105 (35.0%)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;105 (35.0%)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;90 (30.0%)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #e0e0e0; padding: 8px 12px;&quot;&gt;1.506&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;All pass. Run 2 is the instructive one: a 27.3% arm against an expected 33.3%, six points off, &lt;em&gt;and the test is right not to flag it&lt;/em&gt;. At n = 300 one standard deviation per arm is &amp;asymp; 2.7 percentage points, so a six-point swing is unremarkable. If your gut said &quot;27% means the split is broken,&quot; your gut would have failed you in front of an audience. That&#39;s what the statistics are for.&lt;/p&gt;
&lt;p&gt;Steal one calibration decision from this test: the pass threshold is &amp;alpha; = 0.001 (critical value 13.816), not the textbook 0.05. At &amp;alpha; = 0.05 a &lt;em&gt;perfectly configured&lt;/em&gt; rule fails the test one run in twenty by construction, which is terrible odds for a check you might run live. The power loss is irrelevant at this effect size: a genuinely broken split like 50/25/25 produces &amp;chi;&amp;sup2; &amp;asymp; 37.5 at n = 300 and sails past either threshold. Strict alpha costs nothing and buys you a test that never cries wolf on stage.&lt;/p&gt;
&lt;p&gt;Threats to validity, disclosed as ever: the test verifies the &lt;strong&gt;FX bucketing distribution, not content delivery&lt;/strong&gt;. The page reports the arm FX assigned; if the CMS variation failed to load (the silently-wrong row above), the test still passes while every visitor sees master. It&#39;s a distribution check, not an end-to-end content assertion; the diagnostics panel covers the other half.&lt;/p&gt;
&lt;p&gt;Conversions close the loop: a CTA posts to an endpoint that calls &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;TrackEvent(&quot;demo_conversion&quot;)&lt;/span&gt; for the same visitor context, and the stats engine attributes it to whatever arm that visitor occupies. No variation key is sent; attribution is entirely on the visitor ID, which is exactly why the cookie&#39;s stability matters more than any other moving part here.&lt;/p&gt;
&lt;figure style=&quot;margin: 24px 0; text-align: center;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-2/experiment.png&quot; alt=&quot;The experiment collecting decisions and conversions&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;The experiment collecting decisions and conversions in the FX Results view.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2&gt;Plumbing the docs assume you&#39;ll remember&lt;/h2&gt;
&lt;p&gt;Three pieces of lifecycle wiring make the difference between a demo that survives a fortnight and one that fails the morning of the presentation. None of it is exotic and all of it is documented, but each item is easy to skip because the happy path works without it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The datafile is a living document.&lt;/strong&gt; Every decision the SDK makes is computed locally against the datafile, a JSON snapshot of your flags, rules, and traffic allocations served from the CDN. &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;OptimizelyFactory.NewDefaultInstance(sdkKey)&lt;/span&gt; sets up background polling (five-minute default) so dashboard changes propagate without a deploy; for latency-sensitive setups the documented upgrade is a webhook that pings your app the moment the datafile changes. The corollary people miss: &lt;em&gt;pausing an experiment in the dashboard is itself a datafile change&lt;/em&gt;. Whatever your refresh path is, that&#39;s also how fast your kill switch is.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Events are batched, and shutdown is part of the contract.&lt;/strong&gt; &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;TrackEvent&lt;/span&gt; doesn&#39;t call home synchronously. The C# SDK queues impressions and conversions through a batch processor (ten events or thirty seconds, by default) to keep request latency flat. The documented flip side is that you must dispose the client on shutdown so the tail of the queue flushes. In ASP.NET Core that costs nothing if you let it: register the client as a DI singleton and the container disposes it, queue flush included, when the host stops:&lt;/p&gt;
&lt;pre style=&quot;background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;&quot;&gt;services.AddSingleton&amp;lt;OptimizelyClient&amp;gt;(provider =&amp;gt;
{
    var fxLogger = provider.GetRequiredService&amp;lt;ILoggerFactory&amp;gt;().CreateLogger(&quot;OptimizelyFX&quot;);
    var sdkLogger = new OptimizelySdkLogger(fxLogger);

    if (string.IsNullOrWhiteSpace(sdkKey))
    {
        fxLogger.LogWarning(&quot;Optimizely:SdkKey is not configured - fallback mode, all flags report disabled.&quot;);
        return new OptimizelyClient(datafile: null, logger: sdkLogger); // invalid on purpose: no polling, no blocking
    }

    OptimizelyFactory.SetLogger(sdkLogger);
    return OptimizelyFactory.NewDefaultInstance(sdkKey);
});&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Logs don&#39;t exist until you wire them.&lt;/strong&gt; That &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;SetLogger&lt;/span&gt; call looks decorative. It is the opposite. The C# SDK&#39;s shipped default logger formats every message (datafile fetch failures, unknown event keys, audience evaluation warnings) and throws the result away. The adapter is ten lines, and it&#39;s the difference between reading the cause in your console and debugging blind:&lt;/p&gt;
&lt;pre style=&quot;background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;&quot;&gt;public class OptimizelySdkLogger(ILogger logger) : OptimizelySDK.Logger.ILogger
{
    public void Log(SdkLogLevel level, string message)
        =&amp;gt; logger.Log(level switch
        {
            SdkLogLevel.DEBUG =&amp;gt; MelLogLevel.Debug,
            SdkLogLevel.INFO  =&amp;gt; MelLogLevel.Information,
            SdkLogLevel.WARN  =&amp;gt; MelLogLevel.Warning,
            SdkLogLevel.ERROR =&amp;gt; MelLogLevel.Error,
            _ =&amp;gt; MelLogLevel.Information
        }, &quot;{Message}&quot;, message);
}&lt;/pre&gt;
&lt;h2&gt;The same experiment, headless&lt;/h2&gt;
&lt;p&gt;Part 1 called the Graph angle &quot;where it gets interesting.&quot; Here&#39;s the cash value. The repo carries a second head, Next.js on the App Router, serving the &lt;em&gt;same experiment&lt;/em&gt; from the &lt;em&gt;same flag&lt;/em&gt; against the &lt;em&gt;same content&lt;/em&gt;, and the part that surprised people I showed it to is what I &lt;strong&gt;didn&#39;t&lt;/strong&gt; have to do: nothing. No shared session service, no API between the heads, no coordination.&lt;/p&gt;
&lt;p&gt;It works because two independent guarantees compose:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;FX bucketing is a pure function.&lt;/strong&gt; Every SDK - C#, JS, all of them - runs the same MurmurHash over &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;visitorId + experiment&lt;/span&gt;. Same visitor ID in, same arm out, on any stack.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Graph serves variations by name&lt;/strong&gt; (part 1&#39;s contract: opt-in &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variation&lt;/span&gt; argument, arms addressed via &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;_metadata.variation&lt;/span&gt;).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The Next.js side is small enough to show whole. Middleware mints or forwards the same &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;CMS_VisitorId&lt;/span&gt; cookie and hands the fresh ID to the current render via a request header, because the response cookie isn&#39;t visible to the request that set it:&lt;/p&gt;
&lt;pre style=&quot;background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;&quot;&gt;export function middleware(request: NextRequest) {
  const existing = request.cookies.get(&#39;CMS_VisitorId&#39;)?.value;
  if (existing) return NextResponse.next();

  const visitorId = crypto.randomUUID();
  const headers = new Headers(request.headers);
  headers.set(&#39;x-visitor-id&#39;, visitorId);

  const response = NextResponse.next({ request: { headers } });
  response.cookies.set(&#39;CMS_VisitorId&#39;, visitorId, {
    httpOnly: true, sameSite: &#39;lax&#39;,
    secure: process.env.NODE_ENV === &#39;production&#39;,
    maxAge: 60 * 60 * 24 * 365, path: &#39;/&#39;,
  });
  return response;
}&lt;/pre&gt;
&lt;p&gt;One deliberate difference from the MVC head, before someone files it as a bug: this cookie is &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;SameSite=Lax&lt;/span&gt;, not &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;None&lt;/span&gt;. The .NET head needs &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;None&lt;/span&gt; because the CMS editor renders it inside a cross-origin preview iframe; this head doesn&#39;t do editor preview (a scope cut, not an accident), and for a top-level site &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Lax&lt;/span&gt; is the safer default - one more wall against the cross-site POST problem from earlier. The day you wire this head into the editor&#39;s preview, flip it to &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;sameSite: &#39;none&#39;, secure: true&lt;/span&gt; and run local dev over HTTPS (&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;next dev --experimental-https&lt;/span&gt; or a proxy), because browsers refuse &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;SameSite=None&lt;/span&gt; without &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Secure&lt;/span&gt;.&lt;/p&gt;
&lt;p&gt;CSRF splits along the same line. The conversion endpoint here is a plain Route Handler, and Route Handlers get no CSRF protection for free - that&#39;s a Server Actions perk (Next.js compares &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Origin&lt;/span&gt; against &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Host&lt;/span&gt; for those automatically). So the handler validates &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Sec-Fetch-Site&lt;/span&gt;/&lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Origin&lt;/span&gt; itself before calling &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;trackEvent&lt;/span&gt;, with the &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Lax&lt;/span&gt; cookie as the first wall and the header check as the second. Where the MVC head needed an antiforgery token because of its &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;None&lt;/span&gt; cookie, this head needs an origin check because of its handler type. Same threat, two idiomatic answers.&lt;/p&gt;
&lt;p&gt;A Server Component then calls the JS SDK: same flag, same &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;INCLUDE_REASONS&lt;/span&gt;, plus one option the C# factory gives you for free and the JS SDK does not - &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;datafileOptions.autoUpdate&lt;/span&gt;. Without it, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;createInstance&lt;/span&gt; fetches the datafile exactly once and a long-running server keeps serving stale flag state until the next restart:&lt;/p&gt;
&lt;pre style=&quot;background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;&quot;&gt;const client = createInstance({
  sdkKey,
  datafileOptions: { autoUpdate: true, updateInterval: 60_000 },
});

const decision = client
  .createUserContext(visitorId)
  .decide(flagKey, [OptimizelyDecideOption.INCLUDE_REASONS]);&lt;/pre&gt;
&lt;p&gt;One scoping caveat on &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;autoUpdate&lt;/span&gt;: it&#39;s a background timer, so it assumes a long-running Node.js process - a container, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;next start&lt;/span&gt; on a VM. On ephemeral serverless functions the timer dies with the invocation and every cold start fetches its own datafile; in that world the right delivery path is the webhook-into-Edge-Config pattern from the edge section below, not polling.&lt;/p&gt;
&lt;p&gt;And the Graph query asks for the named arm with the disciplined form - &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;include: SOME&lt;/span&gt;, the original kept in the result as the fallback:&lt;/p&gt;
&lt;pre style=&quot;background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;&quot;&gt;query DemoPage($variation: [String!]) {
  _Content(
    where: { _metadata: { types: { in: [&quot;DemoPage&quot;] } } }
    variation: { include: SOME, value: $variation, includeOriginal: true }
  ) {
    items {
      _metadata { key displayName variation }
      ... on DemoPage { DemoTitle }
    }
  }
}&lt;/pre&gt;
&lt;p&gt;When the requested arm is missing from the index, the original arm in the same response is the fallback - the headless mirror of the MVC loader&#39;s &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;TryGet&lt;/span&gt;-then-master. The selection itself is two lines, and the arm is identified strictly by &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;_metadata.variation&lt;/span&gt;, never by dissecting an item ID:&lt;/p&gt;
&lt;pre style=&quot;background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;&quot;&gt;const requested = items.find((i) =&amp;gt; i._metadata?.variation === variationKey);
const original  = items.find((i) =&amp;gt; !i._metadata?.variation);
const item = requested ?? original;&lt;/pre&gt;
&lt;p&gt;Same failure semantics on both stacks, which is the property you actually want, because your experiment analysis shouldn&#39;t have to care which head served the impression.&lt;/p&gt;
&lt;figure style=&quot;margin: 24px 0; text-align: center;&quot;&gt;&lt;img style=&quot;max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;&quot; src=&quot;https://pino-labs.com/blog/content-variations-cms-13-part-2/query.png&quot; alt=&quot;Verifying the variation arms in GraphiQL&quot; /&gt;
&lt;figcaption style=&quot;font-size: 13px; color: #666; margin-top: 6px;&quot;&gt;Verifying the variation arms come back by name in GraphiQL.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The same chi-square test against the Next.js head (n = 300) came back &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;original&lt;/span&gt; 106, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_a&lt;/span&gt; 90, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;variant_b&lt;/span&gt; 104, &amp;chi;&amp;sup2; = 1.518. Pass. Two runtimes, two languages, two delivery mechanisms, one distribution, because it&#39;s one hash.&lt;/p&gt;
&lt;p&gt;Authentication, since this is the part audits ask about: the Next.js server is a Backend-for-Frontend. Graph queries carry the read-only single key in the &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Authorization: epi-single&lt;/span&gt; header (not in the URL, where it lands in every access log), and the key lives in a server-only environment variable that never reaches the client bundle. The HMAC app key and secret exist solely in the CMS&#39;s user-secrets for the sync job. The FX SDK key likewise never ships to the browser; decisions happen server-side. Single key for delivery, HMAC for management, neither in the frontend: that&#39;s the documented split, and it survives a security review.&lt;/p&gt;
&lt;h2&gt;Sharp edges, part 2: all of these drew blood&lt;/h2&gt;
&lt;p&gt;Same tradition as part 1: none of these are hypotheticals. Each cost me real time on a real instance; they&#39;re ordered by how much.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The CMS 12 Graph package kills CMS 13 at startup.&lt;/strong&gt; &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Optimizely.ContentGraph.Cms&lt;/span&gt; 4.4.1 restores fine against CMS 13 (NuGet only &lt;em&gt;warns&lt;/em&gt;, NU1608), then dies on boot with a &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;TypeLoadException&lt;/span&gt; on &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;PropertyContentArea&lt;/span&gt;. CMS 13 wants the version-matched &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Optimizely.Graph.Cms&lt;/span&gt; package (13.0.2 against CMS 13.0.2; the dependency pins are exact, so the package version tracks the CMS version). The registration is &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;services.AddContentGraph()&lt;/span&gt; from &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Optimizely.Graph.DependencyInjection&lt;/span&gt;. Treat NU1608 on an Optimizely package as an error, not a warning.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The C# SDK&#39;s default logger is a no-op.&lt;/strong&gt; Not &quot;logs to a place you forgot to check&quot;: the shipped &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;DefaultLogger.Log&lt;/span&gt; formats the message and discards it. A wrong SDK key, a blocked CDN, an unknown event key in &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;TrackEvent&lt;/span&gt;: all invisible. Ten lines of adapter forwarding &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;OptimizelySDK.Logger.ILogger&lt;/span&gt; into &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Microsoft.Extensions.Logging&lt;/span&gt;, registered via &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;OptimizelyFactory.SetLogger()&lt;/span&gt; before creating the client, and the SDK starts telling you things. Do this on day one.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The JS SDK fetches the datafile once.&lt;/strong&gt; The C# factory polls in the background by default; the JS &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;createInstance&lt;/span&gt; does a one-time fetch unless you pass &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;datafileOptions: { autoUpdate: true }&lt;/span&gt;. Without it, your Next.js server keeps serving yesterday&#39;s flag state until someone restarts it, and &quot;pause the experiment&quot; in the dashboard pauses nothing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The empty-SDK-key fallback is a trap with the factory.&lt;/strong&gt; Feed &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;OptimizelyFactory.NewDefaultInstance&lt;/span&gt; a placeholder key and you get a real polling manager hammering the CDN for a datafile that doesn&#39;t exist (HTTP 403, every interval, forever), and the &lt;em&gt;first&lt;/em&gt; &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Decide&lt;/span&gt; blocks the full default fifteen-second readiness timeout before giving up. First page view after deploy hangs fifteen seconds; everything after is fine; nobody can reproduce it. Construct an intentionally invalid client when the key is absent and &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Decide&lt;/span&gt; fails fast instead.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The sync job and the disabled scheduler.&lt;/strong&gt; Dev environments routinely run &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;SchedulerOptions.Enabled = false&lt;/span&gt;. Content publishes index into Graph incrementally via events, but the &lt;em&gt;initial&lt;/em&gt; full sync is a scheduled job, which never fires on a disabled scheduler. Symptom: Graph authenticates, the schema is live, &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;_Content&lt;/span&gt; returns &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;total: 0&lt;/span&gt;, and every layer of your stack is working correctly. Run the synchronization job manually once from Admin &amp;rarr; Scheduled Jobs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Don&#39;t parse the item ID.&lt;/strong&gt; The pre-release docs show variation items with IDs shaped like &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;Guid_Status_Language_VariantKey&lt;/span&gt;. That&#39;s an example, not a contract. The supported address is &lt;span style=&quot;font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;&quot;&gt;_metadata.variation&lt;/span&gt;; the original arm is the item where it&#39;s null. String-splitting the ID is the kind of thing that works for eleven months and then doesn&#39;t.&lt;/p&gt;
&lt;h2&gt;What I deliberately left out&lt;/h2&gt;
&lt;p&gt;Scope is a feature. Here&#39;s what this build skips and why, so you can disagree on purpose rather than by accident.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;User Profile Service.&lt;/strong&gt; Out of the box, bucketing is sticky because the hash is deterministic: same visitor ID, same arm. But determinism has limits the docs are upfront about: change the traffic allocation mid-flight and some visitors re-bucket. UPS is the documented fix, a key-value store the SDK consults before hashing, pinning each visitor&#39;s first assignment forever. It&#39;s the right call for long-running production experiments; for a demo it&#39;s a database for a problem you don&#39;t have yet.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Allowlists and forced decisions.&lt;/strong&gt; The rule editor lets you pin specific user IDs to specific arms (up to fifty), and the SDK has forced-decision APIs on top. Invaluable for QA (&quot;make me always see variant_b&quot;), and I&#39;d wire it into any real project&#39;s test plan. Here, the &lt;em&gt;New visitor&lt;/em&gt; button (delete the cookie, re-roll the dice) covers the demo&#39;s need with zero configuration.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Edge decisions.&lt;/strong&gt; The architecture above decides on the server, per request. The documented next step for latency-obsessed setups is pushing the datafile to the edge (Vercel&#39;s reference implementation pipes it through a webhook into Edge Config) and bucketing in middleware, before the request touches your origin. Same SDK, same hash, same arms, just earlier. It composes cleanly with everything in this article precisely because the decision logic has no server-side state to migrate.&lt;/p&gt;
&lt;h2&gt;The take&lt;/h2&gt;
&lt;p&gt;The pitch for this pairing is genuinely short. The CMS owns the &lt;em&gt;what&lt;/em&gt;: versioned, published, auditable content arms that editors manage with the tools they already know. FX owns the &lt;em&gt;who and whether&lt;/em&gt;: deterministic assignment, exposure counting, a stats engine that survives scrutiny. The integration surface between two entire products is a string equality, and the same experiment served an MVC head and a headless Next.js head with zero coordination code, holding 33/33/34 within sampling noise on both.&lt;/p&gt;
&lt;p&gt;For the business reader who skimmed to the end: this means an A/B test on real CMS content no longer requires a front-end rebuild, a personalization middleware, or a tag-manager hack that your security team hates. Editors make the arms; the dashboard runs the test; either rendering stack, today&#39;s or the one you migrate to next year, serves it unchanged. The experiment outlives your architecture decisions. That&#39;s the durable part.&lt;/p&gt;
&lt;p&gt;The fragile part is everything in the sharp-edges list, and one sentence summarizes all of it: &lt;strong&gt;the integration fails silent, never loud.&lt;/strong&gt; Wrong package, wrong key, renamed variation, stale datafile, unsynced index: every failure mode degrades to &quot;everyone sees master&quot; with green status everywhere. Wrap it in the diagnostics panel, the startup warnings, the distribution test. The contract is one string; the engineering is making sure you notice when the string stops matching.&lt;/p&gt;
&lt;p&gt;Quiet hero, part 2: now with a stats engine. Still quiet. Still worth it.&lt;/p&gt;
&lt;p&gt;The github repo: &lt;a href=&quot;https://github.com/pino-labs/cms13-fx-content-variations/&quot;&gt;cms13-fx-content-variations&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Further reading&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v13.0.0-CMS/docs/create-content-variations&quot;&gt;Create content variations (CMS 13)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/platform-optimizely/docs/content-variation&quot;&gt;Query content variations in Optimizely Graph for A/B testing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/feature-experimentation/docs/csharp-sdk&quot;&gt;Feature Experimentation: C# SDK&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/feature-experimentation/docs/decide-methods-csharp&quot;&gt;Decide methods and options (INCLUDE_REASONS and friends)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/feature-experimentation/docs/customize-logger-csharp&quot;&gt;Customize the C# SDK logger&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/feature-experimentation/docs/event-batching-csharp&quot;&gt;Event batching and closing the client&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/feature-experimentation/docs/manage-config-datafile&quot;&gt;Datafile management and webhooks&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/feature-experimentation/docs/ensure-consistent-visitor-bucketing&quot;&gt;Ensure consistent visitor bucketing (User Profile Service)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/feature-experimentation/docs/nextjs-integration-for-the-react-sdk&quot;&gt;Feature Experimentation: Next.js integration for the React SDK&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/platform-optimizely/docs/api-single-key-auth&quot;&gt;Optimizely Graph authentication: single key and the epi-single header&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/link/450ac91e74d945a8acd8bba5e9a4b447.aspx&quot;&gt;Graph security best practices (BFF pattern)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Part 1: &lt;a href=&quot;https://pino-labs.com/blog/content-variations-cms-13-quiet-hero/&quot;&gt;Content Variations: Optimizely CMS 13&#39;s Quiet Hero&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>            <guid>https://world.optimizely.com/blogs/piotr-nowak---optimizely--azure/dates/2026/6/unlock-experimentation-with-content-variations-in-cms-13/</guid>            <pubDate>Thu, 11 Jun 2026 07:43:54 GMT</pubDate>           <category>Blog post</category></item><item> <title>Content Variations: Optimizely CMS 13&#39;s Quiet Hero</title>            <link>https://world.optimizely.com/blogs/piotr-nowak---optimizely--azure/dates/2026/6/content-variations-optimizely-cms-13s-quiet-hero/</link>            <description>&lt;p&gt;Every release has a headline act. CMS 13&amp;rsquo;s is the obvious trio. Visual Builder became the default editing surface. Optimizely Graph and Opti ID are now mandatory in every license. The whole platform moved to .NET 10. Those got the demo slots and the launch-day screenshots.&lt;/p&gt;
&lt;p&gt;The feature I keep coming back to with clients isn&amp;rsquo;t any of them. It shipped quietly, got one modest line in the GA notes, and it&amp;rsquo;s going to change how editorial teams actually work day to day. Content Variations.&lt;/p&gt;
&lt;p&gt;You only appreciate it after years of building the workarounds it kills. Let me make the case. Then, because these are field notes and not a brochure, I&amp;rsquo;ll show you where the sharp edges are and how we wrapped them.&lt;/p&gt;
&lt;h2&gt;What it actually is&lt;/h2&gt;
&lt;p&gt;Content Variations let an editor keep several published versions of the same content item, in the same language, from one source record. Not a copy in a sibling node. Not a language branch pressed into service as a fake A/B slot. The same item, with more than one published face.&lt;/p&gt;
&lt;p&gt;The mechanics are better than the one-liner suggests. Variations are delta-based. A new variation starts with no property data and stores only the properties you actually change, not a full clone. Each one keeps its own version history and its own publishing lifecycle, and you can publish it independently of the original as long as the source itself is published. If the instance has a content-approval sequence configured, variations go through it too.&lt;/p&gt;
&lt;p&gt;It shows up as a Variations dropdown in the editing toolbar, the &amp;ldquo;Select a variation of the page&amp;rdquo; control, with full autosave. You add one and name it. The name can&amp;rsquo;t include spaces and can&amp;rsquo;t start with a number. The docs use &lt;strong&gt;WinterCampaign&lt;/strong&gt; as their example. Then you&amp;rsquo;re editing a divergent version in place. You can also seed a new variation from an existing one, which copies its content instead of starting from an empty delta. There&amp;rsquo;s one structural rule. You can&amp;rsquo;t create a variation off Root.&lt;/p&gt;
&lt;p&gt;When you have a winner, you promote it back into the canonical line. That&amp;rsquo;s where the first sharp edge hides, so hold that thought.&lt;/p&gt;
&lt;h2&gt;Why this is the quiet hero&lt;/h2&gt;
&lt;p&gt;Remember how we used to do this.&lt;/p&gt;
&lt;p&gt;Want an A/B test on a landing page? You duplicated the page, ran it through whatever experimentation tool you&amp;rsquo;d bolted on, and prayed the URLs and canonical tags behaved. Want different content for different audiences? You either bought a separate personalization product or built a content area full of visibility rules until every page was a logic puzzle. Want a seasonal variant that didn&amp;rsquo;t disturb the permanent URL structure? You forked the tree and took on the cleanup debt.&lt;/p&gt;
&lt;p&gt;All of those share one disease. They treat a variation of one thing as several different things. The tree grew. Reporting fragmented. Six months later nobody could say which of the four near-identical pages was the real one, and everyone was too scared to delete any of them.&lt;/p&gt;
&lt;p&gt;Content Variations collapse that into one canonical record with several published expressions, and a single answer to &amp;ldquo;what is this page.&amp;rdquo; The experimentation, personalization, and campaign work that used to need structural gymnastics now sits inside the content item, where it belongs. It isn&amp;rsquo;t flashy. It&amp;rsquo;s structural, and structural fixes pay off for years.&lt;/p&gt;
&lt;h2&gt;The Graph angle is where it gets interesting&lt;/h2&gt;
&lt;p&gt;CMS 13 is cloud-first and headless. Headless delivery runs through Optimizely Graph. Classic ASP.NET MVC/Razor rendering still works without it, but Graph is the spine. The real question isn&amp;rsquo;t whether editors can make variations. It&amp;rsquo;s how the front end gets the right one back. That&amp;rsquo;s Graph&amp;rsquo;s job, and the design choices here are worth reading closely, because they&amp;rsquo;re also where the risk lives.&lt;/p&gt;
&lt;p&gt;Start with identity. Each variation is identified by a unique string held in a &lt;strong&gt;variation&lt;/strong&gt; field on the content item, the variation key. That field is how you address a variation in a query. There&amp;rsquo;s deliberately no tidy GUID that means &amp;ldquo;this variation.&amp;rdquo; You select one by filtering on the key.&lt;/p&gt;
&lt;p&gt;Then indexing. Every content variation, including unpublished drafts, is indexed to Graph with a unique identifier, so they&amp;rsquo;re discoverable for previews and experiments. The docs are careful about the result. A variation isn&amp;rsquo;t indexed inline with its source. Each one goes in merged with its source as a self-contained document, which is why a delivery query gets back fully resolved content instead of a bare delta. Treat the exact shape of the identifier as internal. It&amp;rsquo;s a composite of content GUID, version status, language, and variation key. The pre-release docs show it as &lt;strong&gt;Guid_Status_Language_VariantKey&lt;/strong&gt;, but that&amp;rsquo;s an example, not a contract. Address variations through &lt;strong&gt;_metadata (&lt;strong&gt;_metadata { key displayName }&lt;/strong&gt;). Don&amp;rsquo;t string-split an id.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;One default decides everything here.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;By default, GraphQL queries don&amp;rsquo;t return variations at all. A standard query gives you only the original, canonical content. To get a variation, or to preview one, you opt in explicitly.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The opt-in is a &lt;strong&gt;variation&lt;/strong&gt; argument. &lt;strong&gt;include&lt;/strong&gt; is an enum (&lt;strong&gt;ALL | SOME | NONE&lt;/strong&gt;), &lt;strong&gt;value&lt;/strong&gt; is an array of variation keys, and &lt;strong&gt;includeOriginal&lt;/strong&gt; is a boolean.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Default: variations are invisible. Canonical content only.
query {
  _Content {
    items { _metadata { key displayName } }
  }
}

# Opt in to everything. Now you own whatever comes back.
query {
  _Content(variation: { include: ALL, includeOriginal: true }) {
    items { _metadata { key displayName } }
  }
}

# The disciplined form. Name the arms you actually want.
query {
  _Content(variation: { include: SOME, value: [&quot;WinterCampaign&quot;], includeOriginal: true }) {
    items { _metadata { key displayName } }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The C# SDK (&lt;strong&gt;Optimizely.Graph.Cms.Query&lt;/strong&gt;) mirrors this with &lt;strong&gt;SetVariation(...)&lt;/strong&gt;, so the discipline is the same whether you write GraphQL or build the query in C#.&lt;/p&gt;
&lt;p&gt;That default is a safety valve, and a good one. A developer who&amp;rsquo;s never heard of variations writes the obvious query and never accidentally serves experimental content to the public. The flip side is that the moment you opt in, you own everything that comes back. Don&amp;rsquo;t assume a server-side default for &lt;strong&gt;includeOriginal&lt;/strong&gt;. Set it. The SDK makes you anyway. &lt;strong&gt;SetVariation takes &lt;strong&gt;includeOriginal&lt;/strong&gt; as a boolean in its direct overloads, like &lt;strong&gt;SetVariation(includeOriginal: true, &quot;WinterCampaign&quot;)&lt;/strong&gt;, and through the options object in the builder overload. Be that explicit in raw GraphQL too.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;The part the launch post skipped: where this bites&lt;/h2&gt;
&lt;p&gt;This is the OMVP half, the part I&amp;rsquo;d want a client to read before they get excited.&lt;/p&gt;
&lt;h3&gt;1. Drafts are in the index, and &lt;strong&gt;include: ALL&lt;/strong&gt; is a loaded gun&lt;/h3&gt;
&lt;p&gt;Unpublished variation drafts are indexed. The default protects you. The opt-in is yours to get wrong. If a delivery query reaches for &lt;strong&gt;ALL&lt;/strong&gt; without pinning which variation it wants, or someone pastes a preview query into a production resolver, you can ship half-finished, experimental, or off-brand content straight to anonymous visitors. &lt;strong&gt;include: ALL&lt;/strong&gt; belongs in preview and selection logic, never as a casual &amp;ldquo;give me everything.&amp;rdquo; When you want one arm, use &lt;strong&gt;include: SOME&lt;/strong&gt; and name the key.&lt;/p&gt;
&lt;p&gt;Keep one distinction straight. Indexed is not the same as returned. Drafts sit in the index, but anonymous queries still resolve to published content, and pulling unpublished variation content needs a preview-authenticated context. The sharp public-leak risk is published variation content slipping out through an unscoped &lt;strong&gt;ALL&lt;/strong&gt;, with draft leakage on top wherever a preview surface gets misused.&lt;/p&gt;
&lt;h3&gt;2. Promotion is two operations, and only one is dangerous&lt;/h3&gt;
&lt;p&gt;This is the bit I see told wrong most often, so let&amp;rsquo;s be exact.&lt;/p&gt;
&lt;p&gt;&amp;ldquo;Copy changes to Original&amp;rdquo; pushes the variation&amp;rsquo;s modified (delta) properties back onto the original. If the original already has a published common draft, you get a new draft version of the original. It doesn&amp;rsquo;t overwrite the live published version, it doesn&amp;rsquo;t auto-publish, and version history stays intact. An editor still has to publish that draft. The UI even says &amp;ldquo;Changes copied to Original!&amp;rdquo; Nothing is destroyed, so this path is recoverable. It&amp;rsquo;s a merge into a new version, not a clobber.&lt;/p&gt;
&lt;p&gt;Promoting a variation as the default version is the sharp one. It creates a new version of the source, merges the variation&amp;rsquo;s modified properties in, and can delete the variation afterwards. Because variations are deltas, only the properties the variation actually changed get merged. But deleting the variation takes that test arm&amp;rsquo;s separate history with it.&lt;/p&gt;
&lt;p&gt;The risk is real and specific. It lives in the promote-as-default-and-delete path, not in every promote. One confident click on the wrong option can wipe a test arm&amp;rsquo;s history for good. That needs process, not just permissions, and ideally a diff in front of the editor before the merge runs.&lt;/p&gt;
&lt;h3&gt;3. Deltas are bigger than editors think&lt;/h3&gt;
&lt;p&gt;Delta storage sounds surgical. It isn&amp;rsquo;t, quite. The granularity is property-level. Change one value inside a complex property and the whole property gets copied into the delta. There&amp;rsquo;s no finer sub-field delta. It bites hardest on Visual Builder Experience content. The Experience composition is itself a complex property, so changing a single block value pulls the entire Outline and its Sections into the delta. &amp;ldquo;I only touched the hero&amp;rdquo; is rarely true at the storage layer. That changes how much actually diverges, which changes what a promote or a re-sync does.&lt;/p&gt;
&lt;p&gt;I didn&amp;rsquo;t want this one to stay prose, so I measured it at the persistence layer. The harness, the byte dump, and the before/after table are in &lt;a href=&quot;#from-prose-to-proof-two-measurements&quot;&gt;&lt;em&gt;From prose to proof&lt;/em&gt;&lt;/a&gt; below. Short version. Touching one string inside one block of a Visual Builder Experience wrote ~3.7 KB of delta, the whole composition, for an 18-byte edit. That&amp;rsquo;s a ~207&amp;times; blow-up. Even a plain &lt;strong&gt;ContentArea&lt;/strong&gt; amplified ~37&amp;times;.&lt;/p&gt;
&lt;h3&gt;4. Reference integrity has gaps&lt;/h3&gt;
&lt;p&gt;Two limitations sit right there in the docs&amp;rsquo; own Initial Phase Limitations.&lt;/p&gt;
&lt;p&gt;Variations currently vary only localizable properties. Non-localizable, culture-invariant properties can&amp;rsquo;t diverge. The &amp;ldquo;currently&amp;rdquo; is the docs&amp;rsquo; word, so treat it as release-bound and re-check it on whatever version you ship.&lt;/p&gt;
&lt;p&gt;Softlinks aren&amp;rsquo;t generated for published variations. Any reference that exists only inside a variation, a link, a content-area item, a media usage, is invisible to the platform&amp;rsquo;s reference tracking.&lt;/p&gt;
&lt;p&gt;The consequence is the same either way. If your governance, link-checking, or &amp;ldquo;where is this used&amp;rdquo; reporting leans on reference data, it&amp;rsquo;s partly blind to variation-only references. Add experimental URLs to that and you have a real duplicate-content and broken-reference risk that won&amp;rsquo;t show up where you normally look.&lt;/p&gt;
&lt;h3&gt;5. Governance scales with the surface, and most teams forget&lt;/h3&gt;
&lt;p&gt;Approval workflow covers variations if you&amp;rsquo;ve set it up. But variations multiply the number of things that can be published. If your approval gate only sits on the main editorial flow, you&amp;rsquo;ve quietly opened a side door. Nothing in the box stops a page from collecting fourteen orphaned variations nobody remembers making. Variation sprawl is the new tree sprawl. It&amp;rsquo;s just harder to see, because it doesn&amp;rsquo;t show up as nodes.&lt;/p&gt;
&lt;h3&gt;6. Index state is an environment footgun&lt;/h3&gt;
&lt;p&gt;A feature flag controls variation indexing, and flipping it off takes the index with it. Clean kill switch, and an easy thing to get wrong across environments. Flag and index state can drift, and an indexing gap is silent. That silent failure is field observation, not a documented guarantee. A variation that should be live just isn&amp;rsquo;t in the index, your previews and experiments quietly vanish, and nothing throws. The pre-release content-variations docs call this out and the GA notes don&amp;rsquo;t repeat it, so confirm it on the build you ship. I wouldn&amp;rsquo;t hard-code a flag name in a runbook either. Treat it as the variations feature toggle and check it per environment. Silent failure is the worst kind.&lt;/p&gt;
&lt;h3&gt;7. Pulling variation sets at scale costs you on caching&lt;/h3&gt;
&lt;p&gt;This one is a general Graph property, and it&amp;rsquo;s documented. Broad &lt;strong&gt;items&lt;/strong&gt; queries trash your cache. They force it to &amp;ldquo;account for potential changes across all content,&amp;rdquo; in the docs&amp;rsquo; words, so it invalidates constantly. Narrow single-&lt;strong&gt;item&lt;/strong&gt; lookups cache cleanly and hit far more often. The queries you write to pull variations across content are exactly the broad &lt;strong&gt;items&lt;/strong&gt; kind. The docs don&amp;rsquo;t tie variations to caching directly, but the query shape you reach for is the one that caches worst. On a busy site that&amp;rsquo;s a real line item, not a footnote.&lt;/p&gt;
&lt;p&gt;&amp;ldquo;Trash your cache&amp;rdquo; is a claim with no number attached, so I attached one. The environment, the harness, and the measured hit-rate table for &lt;strong&gt;item&lt;/strong&gt; vs &lt;strong&gt;items&lt;/strong&gt; and &lt;strong&gt;SOME&lt;/strong&gt; vs &lt;strong&gt;ALL&lt;/strong&gt; are in &lt;a href=&quot;#from-prose-to-proof-two-measurements&quot;&gt;&lt;em&gt;From prose to proof&lt;/em&gt;&lt;/a&gt; below. Short version. In a deterministic cache model of a 5,000-page set, a narrow &lt;strong&gt;item&lt;/strong&gt; lookup held a 99.2% hit-rate while &lt;strong&gt;items(limit:100)&lt;/strong&gt; with &lt;strong&gt;include: ALL&lt;/strong&gt; collapsed to 7.7%. A single base publish invalidates one &lt;strong&gt;item&lt;/strong&gt; key but hundreds of broad-listing keys.&lt;/p&gt;
&lt;p&gt;None of this is a reason to avoid the feature. It&amp;rsquo;s a reason to wrap it.&lt;/p&gt;
&lt;h2&gt;From prose to proof: two measurements&lt;/h2&gt;
&lt;p&gt;Two of the edges above are the kind of claim a reader has to take on faith. The delta is &amp;ldquo;coarser than it looks.&amp;rdquo; Broad queries &amp;ldquo;trash the cache.&amp;rdquo; Faith is the wrong currency for an architecture decision. Both are measured below, with the environment described and a harness you can re-run. Both experiments ship in the add-on&amp;rsquo;s &lt;a href=&quot;#&quot;&gt;&lt;strong&gt;tools/research/&lt;/strong&gt;&lt;/a&gt; folder and were run for this article. &lt;strong&gt;dotnet run -c Release&lt;/strong&gt; reproduces the tables byte-for-byte (deterministic, seeded), and the exact build/date stamp lives in the generated &lt;strong&gt;RESULTS.md&lt;/strong&gt;. One honesty note up front. The delta figures are real serialization measurements of representative property graphs, not a dump from a live store (&lt;strong&gt;DeltaProbe.cs&lt;/strong&gt; is provided for that). The cache figures come from a deterministic discrete-event model of Graph&amp;rsquo;s documented invalidation rule. The model measures the hit-rate that follows from that rule under stated fragmentation assumptions. The direction is robust, but absolute magnitudes, and any wall-clock latency (p50/p99), need the live gateway and the &lt;strong&gt;graph-cache.k6.js&lt;/strong&gt; script, which isn&amp;rsquo;t run here. The full list of what these caveats mean is in the &lt;em&gt;Threats to validity&lt;/em&gt; section below.&lt;/p&gt;
&lt;h3&gt;Measuring the delta: what &amp;ldquo;I only touched the hero&amp;rdquo; actually writes&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;The claim under test.&lt;/strong&gt; Changing a single value inside a complex property copies the entire property into the variation delta, and for a Visual Builder Experience that means the whole Outline + Sections, not the field you touched.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Method.&lt;/strong&gt; A CMS 13 variation version stores only the properties that diverge from its source. There&amp;rsquo;s no public &amp;ldquo;give me the raw delta bytes&amp;rdquo; call, but you can reconstruct it faithfully. Load the source version and the variation version, walk the property bag, and for each property serialize both values and compare. Properties whose serialized bytes differ are the delta. Their serialized size is what diverged. This byte-diff is the same set Graph merges into the variation document, so it&amp;rsquo;s a faithful proxy for the changed-property payload a variation carries. The exact on-disk bytes can differ, which is what the Threats to validity note covers.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// DeltaProbe.cs - reconstructs a variation&#39;s delta by byte-diffing it against its source version.
// Run once on a freshly-seeded variation (expect: empty delta), then again after touching ONE block.
public sealed record DeltaRow(string Property, string Type, bool InDelta, int Bytes);

public sealed class DeltaProbe
{
    private readonly IContentLoader _loader;
    public DeltaProbe(IContentLoader loader) =&amp;gt; _loader = loader;

    public IReadOnlyList&amp;lt;DeltaRow&amp;gt; Diff(ContentReference original, ContentReference variation)
    {
        var src = _loader.Get&amp;lt;IContent&amp;gt;(original);
        var var_ = _loader.Get&amp;lt;IContent&amp;gt;(variation);
        var rows = new List&amp;lt;DeltaRow&amp;gt;();

        foreach (var vp in var_.Property)
        {
            var sp = src.Property[vp.Name];
            var sb = Serialize(sp?.Value);
            var vb = Serialize(vp.Value);
            var inDelta = !sb.AsSpan().SequenceEqual(vb);   // differs =&amp;gt; part of the delta
            rows.Add(new DeltaRow(vp.Name, vp.GetType().Name, inDelta, inDelta ? vb.Length : 0));
        }
        return rows;
    }

    // Stable, value-only serialization so the comparison reflects content, not object identity.
    private static byte[] Serialize(object? value) =&amp;gt; value is null
        ? Array.Empty&amp;lt;byte&amp;gt;()
        : Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value, JsonOpts));

    private static readonly JsonSerializerOptions JsonOpts =
        new() { ReferenceHandler = ReferenceHandler.IgnoreCycles, WriteIndented = false };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Environment.&lt;/strong&gt; Figures come from the deterministic harness in &lt;strong&gt;tools/research&lt;/strong&gt;. It builds two representative property graphs. One is a standard page with a&amp;nbsp;&lt;strong&gt;ContentArea of local blocks. The other is a Visual Builder Experience of five sections (hero, a 3-up feature row, a story split, a product band, a signup). It serializes each, applies exactly one nested edit (the hero headline &lt;strong&gt;&quot;Winter Sale&quot;&lt;/strong&gt; &amp;rarr; &lt;strong&gt;&quot;Winter Sale - Up to 40% off&quot;, an 18-byte change), and re-serializes. The &lt;strong&gt;DeltaProbe.cs&lt;/strong&gt; above is the same byte-diff run against live content via &lt;strong&gt;IContentLoader&lt;/strong&gt;. The harness just removes the need for a seeded site to reproduce the effect.&lt;/strong&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Result, standard page (ContentArea).&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;In delta?&lt;/th&gt;
&lt;th&gt;Serialized bytes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Heading&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PropertyString&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MainBody&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PropertyXhtmlString&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MainContentArea&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PropertyContentArea&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;664&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The headline lives in a local block inside &lt;strong&gt;MainContentArea&lt;/strong&gt;. Editing it marked the whole content-area property dirty, every block, not just the changed one. 664 bytes landed in the delta for an 18-byte edit, a ~37&amp;times; amplification.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Result, Visual Builder Experience page.&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;In delta?&lt;/th&gt;
&lt;th&gt;Serialized bytes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Name&lt;/td&gt;
&lt;td&gt;(metadata)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Composition&lt;/td&gt;
&lt;td&gt;Experience composition (Outline + Sections)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3,727&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;There it is, at the persistence layer. The Experience composition is one complex property. Touching one string in one block rewrote the entire Outline and all five sections into the delta. That&amp;rsquo;s 3,727 bytes against the 18 bytes the edit actually represents, a ~207&amp;times; amplification. And that&amp;rsquo;s a deliberately modest composition. A production Experience with imagery, more blocks, and richer settings runs to tens of KB, which pushes the ratio into the thousands. &amp;ldquo;I only touched the hero&amp;rdquo; is false where it counts. Seed twenty audience variations of this one page and you&amp;rsquo;ve written ~75 KB of near-duplicate composition, and every promote or sync moves all of it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why it matters beyond disk.&lt;/strong&gt; That delta is exactly what a promote merges and what &lt;em&gt;Sync from default&lt;/em&gt; has to reconcile. A coarse delta means a coarse merge surface. More properties in play, more chances for a stale-conflict, more to diff in front of the editor. The byte number isn&amp;rsquo;t vanity. It&amp;rsquo;s the size of the blast radius.&lt;/p&gt;
&lt;h3&gt;Measuring the cache: &lt;strong&gt;item&lt;/strong&gt; vs &lt;strong&gt;items&lt;/strong&gt;, and the cost of &lt;strong&gt;include: ALL&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;The claim under test.&lt;/strong&gt; Broad &lt;strong&gt;items&lt;/strong&gt; queries cache badly, narrow &lt;strong&gt;item&lt;/strong&gt; lookups cache well, and opting into variations with &lt;strong&gt;include: ALL&lt;/strong&gt; makes it worse. A single master publish invalidates broadly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Environment.&lt;/strong&gt; Two layers. The hit-rate numbers below come from a deterministic discrete-event model (&lt;strong&gt;tools/research&lt;/strong&gt;, run on .NET 10) of Graph&amp;rsquo;s documented cache rule. A result is keyed by its query, and a write invalidates every key whose result could change. The model uses the article&amp;rsquo;s dataset shape, 5,000 pages and 500 varied &amp;times; 3 = 1,500 variants, exercised over a fixed 100-id working set with 200,000 reads and one publish per 100 reads. It models the decisive variable explicitly. A narrow &lt;strong&gt;item&lt;/strong&gt; lookup has one key per id, while a broad &lt;strong&gt;items&lt;/strong&gt; listing fragments across filter/sort/page keys (150), with &lt;strong&gt;SOME&lt;/strong&gt; (300) and &lt;strong&gt;ALL&lt;/strong&gt; (600) fragmenting further. The &lt;strong&gt;graph-cache.k6.js&lt;/strong&gt; script below is the live counterpart that measures p50/p99 latency against a real gateway. k6 wasn&amp;rsquo;t run for this article, so latency is left to you. The model measures the hit-rate that follows from the cache-key/invalidation rule under those stated fragmentation assumptions. The ordering is robust. The exact percentages move with the assumptions.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// graph-cache.k6.js &amp;mdash; compares cache behaviour of item vs items, and SOME vs ALL.
import http from &#39;k6/http&#39;;
import { Trend, Rate } from &#39;k6/metrics&#39;;

const URL = `https://cg.optimizely.com/content/v2?auth=${__ENV.KEY}`;
const hit = {};      // Rate per scenario
const lat = {};      // Trend per scenario
for (const s of [&#39;item&#39;, &#39;item_some&#39;, &#39;items&#39;, &#39;items_some&#39;, &#39;items_all&#39;]) {
  hit[s] = new Rate(`hit_${s}`);
  lat[s] = new Trend(`lat_${s}`, true);
}

const Q = {
  item:       (id) =&amp;gt; `{ _Content(where:{_metadata:{key:{eq:&quot;${id}&quot;}}}, limit:1){ items{ _metadata{ key displayName } } } }`,
  item_some:  (id) =&amp;gt; `{ _Content(where:{_metadata:{key:{eq:&quot;${id}&quot;}}}, limit:1, variation:{ include: SOME, value:[&quot;WinterCampaign&quot;], includeOriginal:true }){ items{ _metadata{ key } } } }`,
  items:      ()   =&amp;gt; `{ _Content(limit:100){ items{ _metadata{ key displayName } } } }`,
  items_some: ()   =&amp;gt; `{ _Content(limit:100, variation:{ include: SOME, value:[&quot;WinterCampaign&quot;], includeOriginal:true }){ items{ _metadata{ key } } } }`,
  items_all:  ()   =&amp;gt; `{ _Content(limit:100, variation:{ include: ALL, includeOriginal:true }){ items{ _metadata{ key } } } }`,
};

export default function () {
  const id = IDS[Math.floor(Math.random() * IDS.length)]; // fixed 100-id working set
  for (const s of Object.keys(Q)) {
    const body = JSON.stringify({ query: s.startsWith(&#39;item&#39;) ? Q[s](id) : Q[s]() });
    const res = http.post(URL, body, { headers: { &#39;Content-Type&#39;: &#39;application/json&#39; } });
    const cached = (res.headers[&#39;X-Cache&#39;] || res.headers[&#39;Cf-Cache-Status&#39;] || &#39;&#39;).toLowerCase().includes(&#39;hit&#39;);
    hit[s].add(cached);
    lat[s].add(res.timings.duration);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Result, hit-rate by query shape (model).&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query shape&lt;/th&gt;
&lt;th&gt;Variation arg&lt;/th&gt;
&lt;th&gt;Reads&lt;/th&gt;
&lt;th&gt;Origin fetches&lt;/th&gt;
&lt;th&gt;Hit-rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;item(key)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;none (canonical)&lt;/td&gt;
&lt;td&gt;200,000&lt;/td&gt;
&lt;td&gt;1,673&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;99.2%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;item(key)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;SOME [&quot;WinterCampaign&quot;]&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;200,000&lt;/td&gt;
&lt;td&gt;1,885&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;99.1%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;items(limit:100)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;200,000&lt;/td&gt;
&lt;td&gt;129,679&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;35.2%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;items(limit:100)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;SOME [key]&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;200,000&lt;/td&gt;
&lt;td&gt;165,132&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;17.4%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;items(limit:100)&lt;/td&gt;
&lt;td&gt;ALL + &lt;strong&gt;includeOriginal&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;200,000&lt;/td&gt;
&lt;td&gt;184,516&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.7%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Hit-rate (modelled), drawn so the cliff is obvious:&lt;/p&gt;
&lt;pre&gt;&lt;strong&gt;item            ██████████████████████████████████████████████████ &amp;asymp;99%
item + SOME     ██████████████████████████████████████████████████ &amp;asymp;99%
items           ██████████████████                                 &amp;asymp;35%
items + SOME    █████████                                          &amp;asymp;17%
items + ALL     ████                                               &amp;lt;10%
                0%        25%        50%        75%        100%&lt;/strong&gt;&lt;/pre&gt;
&lt;p&gt;Two readings. First, query shape dominates. A narrow &lt;strong&gt;item&lt;/strong&gt; lookup is a clean, stable per-id key that effectively always hits, 1,673 origin fetches across 200,000 reads (99.2%). A broad &lt;strong&gt;items&lt;/strong&gt; listing fragments across keys, each reused less and invalidated whenever any member changes, so it fell to 35.2%. Second, opting into variations compounds it. &lt;strong&gt;SOME&lt;/strong&gt; halves the broad hit-rate again (17.4%). &lt;strong&gt;ALL&lt;/strong&gt; folds all 1,500 variants into the candidate space and floors it at 7.7%, about 110&amp;times; the origin traffic of the narrow lookup (184,516 vs 1,673 fetches).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Result, invalidation cost of one base publish (model).&lt;/strong&gt; Warm every key, publish a single base content inside the working set, then count the keys each shape must re-fetch from origin.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query shape&lt;/th&gt;
&lt;th&gt;Warm keys&lt;/th&gt;
&lt;th&gt;Keys invalidated by 1 publish&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;item&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;item + SOME&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;items&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;150&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;items + SOME&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;300&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;items + ALL&lt;/td&gt;
&lt;td&gt;600&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;600&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;That&amp;rsquo;s the asymmetry that matters. A base publish punches a single hole in the &lt;strong&gt;item&lt;/strong&gt; cache, the published id, nobody else. The same publish poisons every broad listing key that could contain it, because the gateway can&amp;rsquo;t prove it doesn&amp;rsquo;t, so it invalidates the whole family. The variation-inventory queries you&amp;rsquo;re tempted to write (&amp;ldquo;show me every variation across the site&amp;rdquo;) are precisely this broad &lt;strong&gt;items&lt;/strong&gt; shape. That&amp;rsquo;s why the Auditor&amp;rsquo;s grid resolves variation keys through narrow per-&lt;strong&gt;item&lt;/strong&gt; lookups and batches the deliverability probe one round-trip per content id, instead of one broad &lt;strong&gt;items(variation: ALL)&lt;/strong&gt; sweep. The measurement drove the design.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Repeatability.&lt;/strong&gt; The whole harness ships in the add-on&amp;rsquo;s &lt;strong&gt;tools/research/&lt;/strong&gt; folder: &lt;strong&gt;DeltaExperiment.cs&lt;/strong&gt;, &lt;strong&gt;CacheExperiment.cs&lt;/strong&gt;, the &lt;strong&gt;graph-cache.k6.js&lt;/strong&gt; live script, and the generated &lt;strong&gt;RESULTS.md&lt;/strong&gt;. &lt;strong&gt;dotnet run -c Release&lt;/strong&gt; reproduces both tables byte-for-byte (the run is deterministic, seeded &lt;strong&gt;1337&lt;/strong&gt;). Point &lt;strong&gt;DeltaProbe.cs&lt;/strong&gt; and the k6 script at a live CMS 13 + Graph environment for your own live numbers. Absolute values move with dataset size, composition size, and gateway region. The cliff between &lt;strong&gt;item&lt;/strong&gt; and &lt;strong&gt;items + ALL&lt;/strong&gt;, and the property-level delta amplification on Experience content, do not.&lt;/p&gt;
&lt;h3&gt;Threats to validity&lt;/h3&gt;
&lt;p&gt;Both experiments confirm the direction of the claims. Be precise about what they don&amp;rsquo;t prove.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Delta is a serialization model, not a live store dump.&lt;/strong&gt; It demonstrates the mechanism (any nested change re-serializes the whole complex property) using representative graphs. The exact on-disk bytes CMS 13 writes can differ. Run &lt;strong&gt;DeltaProbe.cs&lt;/strong&gt; against real content for authoritative figures.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Delta magnitude scales with composition size.&lt;/strong&gt; The 207&amp;times; here is from a deliberately modest five-section Experience (3.7 KB). A production Experience with imagery and richer settings amplifies far more. The ratio is not a constant.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The ContentArea result assumes local/inline blocks.&lt;/strong&gt; If the area holds shared block references, editing the referenced block changes a different content item, not the page&amp;rsquo;s delta. The Experience composition embeds its blocks, so that case holds robustly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cache numbers are a model, not a live-gateway measurement.&lt;/strong&gt; Hit-rates follow from assumed key-fragmentation counts (150/300/600) and an invalidation rule where every listing key covers the whole working set. That rule maximises the broad-query penalty. A gateway that scopes invalidation more narrowly would show a softer, same-direction cliff. Only &lt;strong&gt;graph-cache.k6.js&lt;/strong&gt; against a real gateway settles the magnitudes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No latency was measured.&lt;/strong&gt; Every p50/p99 statement is deferred to the live k6 run. None is asserted here.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these soften the practical takeaway. Narrow lookups and bounded variation scoping win. Coarse deltas and &lt;strong&gt;include: ALL&lt;/strong&gt; lose. But they mark exactly where &amp;ldquo;measured&amp;rdquo; ends and &amp;ldquo;modelled&amp;rdquo; begins.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live spot-check.&lt;/strong&gt; I did point a read-only delivery key at a real Graph gateway to ground-truth the mechanism, not the magnitudes. Confirmed live. The schema accepts the &lt;strong&gt;variation: { include: ALL | SOME | NONE, includeOriginal }&lt;/strong&gt; argument. The gateway caches with &lt;strong&gt;Cache-Control: public, max-age=86400&lt;/strong&gt; and reports &lt;strong&gt;CF-Cache-Status: HIT/MISS&lt;/strong&gt;. A cache-miss &lt;strong&gt;include: ALL&lt;/strong&gt; query was the costliest at the origin (p50 ~506 ms vs ~406 ms for a narrow &lt;strong&gt;item&lt;/strong&gt; miss). What that source couldn&amp;rsquo;t validate (it held 24 items and zero variations, and the key can&amp;rsquo;t publish) is the hit-rate cliff at scale and the invalidation asymmetry, which still rest on the model. Mechanism confirmed on a live gateway, magnitudes still modelled.&lt;/p&gt;
&lt;h2&gt;Where the add-on comes in: PiNo Labs Variations Auditor&lt;/h2&gt;
&lt;p&gt;Most of the risks above aren&amp;rsquo;t bugs. They&amp;rsquo;re sharp edges that come with a powerful primitive being honest about what it does. The platform gives you the capability and a sensible default. It doesn&amp;rsquo;t have opinions about how your team should use it. That gap is what a thin add-on fills.&lt;/p&gt;
&lt;p&gt;In our Foundation solution we packaged the governance into a small CMS 13 add-on, &lt;strong&gt;PiNo.Labs.VariationsAuditor&lt;/strong&gt;, that turns the variation estate from a blind spot into something you can see, measure, and act on. It installs as one self-registering protected shell module: an &lt;strong&gt;IConfigurableModule&lt;/strong&gt;, a &lt;strong&gt;ProtectedModuleOptions&lt;/strong&gt; self-registration, an &lt;strong&gt;/api/auditor/*&lt;/strong&gt; REST surface, a shell menu entry, and a React UI embedded in the assembly and served straight from the DLL, with no &lt;strong&gt;wwwroot&lt;/strong&gt; copy step. It authorizes against the canonical CMS roles Opti ID syncs onto the principal, so the gate behaves the same on DXP and locally. Every external dependency it leans on, Graph most of all, is resolved optionally and degrades gracefully when it&amp;rsquo;s missing.&lt;/p&gt;
&lt;p&gt;A fair question before the code. Every platform seam the snippets touch (shell-module and menu registration, &lt;strong&gt;IConfigurableModule&lt;/strong&gt;/&lt;strong&gt;ProtectedModuleOptions&lt;/strong&gt;, the &lt;strong&gt;IContentEvents.PublishedContent&lt;/strong&gt; hook, the versioning APIs) sits inside CMS 13&amp;rsquo;s breaking-change surface. They&amp;rsquo;re shown as they compile against the CMS 13 / .NET 10 assemblies the add-on actually ships on, written for those breaking changes rather than refactored from CMS 12. Here&amp;rsquo;s what it does, mapped to the edges above.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It starts from the one inconvenient truth the platform hands you.&lt;/strong&gt; There&amp;rsquo;s no in-process, site-wide API for &amp;ldquo;all content that has variations.&amp;rdquo; A CMS 13 content variation is just a content version carrying a non-empty &lt;strong&gt;IVersionable.Variation&lt;/strong&gt; key, and a variation&amp;rsquo;s identity is a tuple of content, language, and variation key, not a GUID.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// A CMS 13 &quot;content variation&quot; is a version with a non-empty IVersionable.Variation key.
var variants = versionRepository
    .List(new VersionFilter { ContentLink = contentLink }, 0, max, out _)
    .Where(v =&amp;gt; !string.IsNullOrEmpty(v.Variation));     // empty key == language baseline, not a variant&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Discovery uses Graph to enumerate the site, then reads the authoritative variation keys from &lt;strong&gt;IContentVersionRepository&lt;/strong&gt;. If Graph is down or hasn&amp;rsquo;t finished indexing, it falls back to an in-process content-tree walk. The grid works on day one, indexed or not.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A pre-promotion diff, so promotion is a decision and not a reflex (edges #2 and #3).&lt;/strong&gt; Every destructive action runs a mandatory, conflict-aware dry-run first. The divergence engine computes a property-level diff and a structural content-area diff, so the editor sees exactly which properties will land back on the original. It also catches the dangerous case the platform won&amp;rsquo;t warn about. Promoting a variation whose source has moved on since.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Stale-conflict guard: refuse to silently overwrite newer master changes on promote.
var stale = divergence.GetStaleProperties(target);
if (action == BulkActionType.Promote &amp;amp;&amp;amp; stale.Count &amp;gt; 0 &amp;amp;&amp;amp; !cmd.ForceOverwriteStale)
{
    return Blocked(
        &quot;CRITICAL REVERSION RISK: promoting would overwrite newer changes on the original for: &quot; +
        string.Join(&quot;, &quot;, stale) + &quot;. Re-run with ForceOverwriteStale to proceed.&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Promote is a delta merge, full stop. It overlays only the properties the variation actually overrode onto a writable clone of the published original, and reports which ones it touched. That keeps CMS 13&amp;rsquo;s delta model intact instead of flattening master-only properties. We pulled an earlier &amp;ldquo;replace everything&amp;rdquo; mode because it broke exactly that model. The same dry-run, lock, and status gate guards Unpublish and Delete. The inverse action, &amp;ldquo;Sync from default,&amp;rdquo; lets an editor pull the original&amp;rsquo;s current value back into a stale variation, which is the safe fix for edge #3.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A site-wide orphan and sprawl report, so cleanup actually happens (edge #5).&lt;/strong&gt; The grid is the inventory nobody had. Every variation across the site, the audience it targets (the key resolved to a readable visitor group, because editors think in audiences, not keys), its divergence state, and a needs-attention filter. Multi-select plus a dry-run bulk bar turns unpublishing or deleting a pile of orphaned arms into a two-click, fully previewed operation. Sprawl you can retire.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A deliverability probe that turns silent index failures loud (edges #4 and #6).&lt;/strong&gt; This is the canary for &amp;ldquo;my variation should be live but isn&amp;rsquo;t in Graph.&amp;rdquo; It checks each published variant against the index, batched and de-duplicated per content id so a grid page costs one round-trip instead of N, and flags anything known to CMS but missing from the index as an orphan. Softlinks are blind to variations, so this Graph-backed inventory is the &amp;ldquo;where are my variations, and are they being served&amp;rdquo; view the reference system can&amp;rsquo;t give you. It doesn&amp;rsquo;t lie the other way either. If the Graph client isn&amp;rsquo;t registered at all, the probe returns deliverable rather than painting every variant red.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Optional Graph client, never required. No AddGraphContentClient()? Degrade, don&#39;t crash.
_graph = serviceProvider.GetService&amp;lt;IGraphContentClient&amp;gt;();
...
if (_graph == null) return GraphStatus.Deliverable;        // unknown != orphan; never mislabel
var indexed = await IsIndexedAsync(identity.ContentId, ct);
return indexed ? GraphStatus.Deliverable : GraphStatus.IndexGap;   // IndexGap == orphan, surfaced in the grid&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Proactive drift notifications, so editors come back when something changes (edge #5 again).&lt;/strong&gt; A &lt;strong&gt;StaleDriftDetector&lt;/strong&gt; subscribes to &lt;strong&gt;IContentEvents.PublishedContent&lt;/strong&gt;. When a default publishes, it recomputes its variants&amp;rsquo; staleness and tells the owner of each newly stale variation. The default just moved, your audience is now on yesterday&amp;rsquo;s content for these three properties. It&amp;rsquo;s bounded, de-duplicated per variant and save, runs in its own DI scope, and is wrapped so it can never fail the publish that triggered it. Delivery fans out across pluggable &lt;strong&gt;INotificationChannel&lt;/strong&gt;s (webhook to Slack/Teams, SMTP email, log), all off until the host opts in.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public void Initialize(InitializationEngine context)
    =&amp;gt; context.Locate.ContentEvents().PublishedContent += OnDefaultPublished; // drift stops being silent&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;And the one guardrail that belongs at the delivery layer, not in the editor (edge #1).&lt;/strong&gt; The auditor governs the editorial and estate side, which is the side it can see. The &lt;strong&gt;include: ALL&lt;/strong&gt; problem is a delivery concern, and the cheapest place to close it is a thin convention around your Graph client. A typed query wrapper, or a CI lint over your resolvers, that refuses an unscoped &lt;strong&gt;ALL&lt;/strong&gt; on the production delivery key and makes callers pass an explicit variation key or a preview context. The platform default already protects the naive query. This protects you from the clever one. Run both and you&amp;rsquo;ve covered what&amp;rsquo;s in the estate and what reaches the visitor.&lt;/p&gt;
&lt;p&gt;The point isn&amp;rsquo;t the code. It&amp;rsquo;s the posture. The platform ships the engine and a sensible default. You ship the seatbelts that fit your team&amp;rsquo;s risk tolerance. That&amp;rsquo;s what an add-on is for.&lt;/p&gt;
&lt;p&gt;The full source of the add-on is available on GitHub at &lt;a href=&quot;https://github.com/pino-labs/pino-labs-variations-auditor&quot;&gt;https://github.com/pino-labs/pino-labs-variations-auditor.&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;The take&lt;/h2&gt;
&lt;p&gt;A year from now, Content Variations won&amp;rsquo;t be what people remember about CMS 13. Visual Builder and the headless story will take that slot. But it&amp;rsquo;s the feature that quietly removes a whole category of structural debt teams have carried for years, and it deserved more than one launch-day line.&lt;/p&gt;
&lt;p&gt;Just go in with your eyes open. Drafts live in the index. The opt-in is yours to misuse. Promotion has two doors, and one of them deletes history. Deltas are coarser than they look. Softlinks and index state have edges. Put a thin layer of governance and tooling around the feature: a pre-promotion diff, a site-wide orphan report, a deliverability canary, a delivery-side guard. Then let your editors do the thing they&amp;rsquo;ve always wanted, which is to experiment on a page without turning the content tree into an archaeological dig.&lt;/p&gt;
&lt;p&gt;Quiet hero. Sharp edges. Worth it..&lt;/p&gt;
&lt;h2&gt;Further reading&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v13-Pre-Release/docs/content-variations&quot;&gt;Content Variations (CMS 13 developer documentation)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs/create-content-variation&quot;&gt;Create content variations (editorial workflow)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/platform-optimizely/docs/content-variation&quot;&gt;Query content variations in Optimizely Graph&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/platform-optimizely/docs/graphql-best-practices&quot;&gt;GraphQL best practices (&lt;strong&gt;items&lt;/strong&gt; vs &lt;strong&gt;item&lt;/strong&gt; caching)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v13.0.0-CMS/docs/cms-13-overview&quot;&gt;CMS 13 overview (architecture, mandatory components)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v13.0.0-CMS/docs/cms-13-user-interface&quot;&gt;CMS 13 user interface (toolbar and Variations control)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://support.optimizely.com/hc/en-us/articles/44734633809037-2026-Optimizely-CMS-13-general-availability-GA-release-notes&quot;&gt;CMS 13 GA release notes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://support.optimizely.com/hc/en-us/articles/44937048830221-2026-Optimizely-CMS-13-release-notes&quot;&gt;CMS 13 release notes (rolling)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/link/5ffa0c0d7b184f049358e62d7fcad94f.aspx&quot;&gt;Introducing the Optimizely CMS 13 Graph SDK&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/link/232c025ff23340d5a2301c10eadf3ad7.aspx&quot;&gt;A day in the life of an Optimizely OMVP (CMS 13)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>            <guid>https://world.optimizely.com/blogs/piotr-nowak---optimizely--azure/dates/2026/6/content-variations-optimizely-cms-13s-quiet-hero/</guid>            <pubDate>Mon, 08 Jun 2026 06:34:41 GMT</pubDate>           <category>Blog post</category></item><item> <title>Running 64 Sites on Headless Optimizely CMS with GraphQL</title>            <link>https://world.optimizely.com/blogs/piotr-nowak---optimizely--azure/dates/2026/3/running-64-sites-on-headless-optimizely-cms-with-graphql/</link>            <description>&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;64 websites. Live. Running on headless Optimizely with GraphQL.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;We just wrapped a major rollout for our Rockwool Digital Experience Platform&amp;nbsp; and the early numbers already tell a story.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;This wasn&#39;t about moving pixels. It was about rethinking how users actually interact with the brand. Here&#39;s what we&#39;re seeing so far:&lt;/p&gt;
&lt;ul class=&quot;[li_&amp;amp;]:mb-0 [li_&amp;amp;]:mt-1 [li_&amp;amp;]:gap-1 [&amp;amp;:not(:last-child)_ul]:pb-1 [&amp;amp;:not(:last-child)_ol]:pb-1 list-disc flex flex-col gap-1 pl-8 mb-3&quot;&gt;
&lt;li class=&quot;whitespace-normal break-words pl-2&quot;&gt;&lt;strong&gt;Product navigation is pulling its weight.&lt;/strong&gt; A leaner UI means visitors hit conversion pages faster.&lt;/li&gt;
&lt;li class=&quot;whitespace-normal break-words pl-2&quot;&gt;&lt;strong&gt;Documentation is finally findable.&lt;/strong&gt; Doc downloads among active users have jumped noticeably.&lt;/li&gt;
&lt;li class=&quot;whitespace-normal break-words pl-2&quot;&gt;&lt;strong&gt;Dealer pages are surging.&lt;/strong&gt; A smoother user journey created a much clearer path for low-touch leads.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/5e48ee1ba3064500b3054b1839543c44.aspx&quot; width=&quot;697&quot; height=&quot;366&quot; /&gt;&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;&lt;strong&gt;What it took behind the scenes&lt;/strong&gt;&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;This was not a small lift. Making it work across 64 sites meant going deep into the stack:&lt;/p&gt;
&lt;ul class=&quot;[li_&amp;amp;]:mb-0 [li_&amp;amp;]:mt-1 [li_&amp;amp;]:gap-1 [&amp;amp;:not(:last-child)_ul]:pb-1 [&amp;amp;:not(:last-child)_ol]:pb-1 list-disc flex flex-col gap-1 pl-8 mb-3&quot;&gt;
&lt;li class=&quot;whitespace-normal break-words pl-2&quot;&gt;&lt;strong&gt;Custom scaling:&lt;/strong&gt; We extended Algolia queries and our Product Service to fully support sub-brands.&lt;/li&gt;
&lt;li class=&quot;whitespace-normal break-words pl-2&quot;&gt;&lt;strong&gt;Independent pipelines:&lt;/strong&gt; Autonomous release processes and brand-specific Next.js configs keep things flexible.&lt;/li&gt;
&lt;li class=&quot;whitespace-normal break-words pl-2&quot;&gt;&lt;strong&gt;Smart routing:&lt;/strong&gt; Azure Front Door handles the transition from legacy systems to the new Next.js environment.&lt;/li&gt;
&lt;li class=&quot;whitespace-normal break-words pl-2&quot;&gt;&lt;strong&gt;The details:&lt;/strong&gt; Complex language mapping, domain consolidation, Inriver content tagging, massive redirect prep&amp;nbsp; all of it had to land.&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;The result? A platform that doesn&#39;t just look better - it actually performs. We&#39;ve built a foundation where updates ship in days, not months.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;In the next article, I&#39;d like to walk through the underlying architecture. I hope.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/piotr-nowak---optimizely--azure/dates/2026/3/running-64-sites-on-headless-optimizely-cms-with-graphql/</guid>            <pubDate>Sat, 14 Mar 2026 17:57:22 GMT</pubDate>           <category>Blog post</category></item><item> <title>64 Websites Live on Headless Optimizely with GraphQL</title>            <link>https://pino-labs.com/blog/64-websites-headless-optimizely-graphql/</link>            <description>Major rollout for Digital Experience Platform complete. Product navigation working harder, documentation downloads up, dealer traffic growing.</description>            <guid>https://pino-labs.com/blog/64-websites-headless-optimizely-graphql/</guid>            <pubDate>Fri, 13 Mar 2026 00:00:00 GMT</pubDate>           <category>Blog post</category></item><item> <title>Moving Beyond &quot;Lift and Shift&quot;: Lessons from an Optimizely DXP Migration</title>            <link>https://pino-labs.com/blog/moving-beyond-lift-and-shift-dxp-migration/</link>            <description>Successfully migrated from legacy Windows to Optimizely DXP Cloud-native Linux. Cross-platform compatibility, WAF rules, and Infrastructure as Code.</description>            <guid>https://pino-labs.com/blog/moving-beyond-lift-and-shift-dxp-migration/</guid>            <pubDate>Sat, 14 Feb 2026 00:00:00 GMT</pubDate>           <category>Blog post</category></item><item> <title>Rockworld 2.0: A Strategic System Migration</title>            <link>https://pino-labs.com/blog/rockworld-2-strategic-system-migration/</link>            <description>Complex migration from legacy system to modern headless architecture using Optimizely Headless CMS, GraphQL, Next.js, and full Azure infrastructure.</description>            <guid>https://pino-labs.com/blog/rockworld-2-strategic-system-migration/</guid>            <pubDate>Sat, 14 Jun 2025 00:00:00 GMT</pubDate>           <category>Blog post</category></item></channel>
</rss>