Piotr
Jun 8, 2026
  56
(0 votes)

Content Variations: Optimizely CMS 13's Quiet Hero

Every release has a headline act. CMS 13’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.

The feature I keep coming back to with clients isn’t any of them. It shipped quietly, got one modest line in the GA notes, and it’s going to change how editorial teams actually work day to day. Content Variations.

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’ll show you where the sharp edges are and how we wrapped them.

What it actually is

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.

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.

It shows up as a Variations dropdown in the editing toolbar, the “Select a variation of the page” control, with full autosave. You add one and name it. The name can’t include spaces and can’t start with a number. The docs use WinterCampaign as their example. Then you’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’s one structural rule. You can’t create a variation off Root.

When you have a winner, you promote it back into the canonical line. That’s where the first sharp edge hides, so hold that thought.

Why this is the quiet hero

Remember how we used to do this.

Want an A/B test on a landing page? You duplicated the page, ran it through whatever experimentation tool you’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’t disturb the permanent URL structure? You forked the tree and took on the cleanup debt.

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.

Content Variations collapse that into one canonical record with several published expressions, and a single answer to “what is this page.” The experimentation, personalization, and campaign work that used to need structural gymnastics now sits inside the content item, where it belongs. It isn’t flashy. It’s structural, and structural fixes pay off for years.

The Graph angle is where it gets interesting

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’t whether editors can make variations. It’s how the front end gets the right one back. That’s Graph’s job, and the design choices here are worth reading closely, because they’re also where the risk lives.

Start with identity. Each variation is identified by a unique string held in a variation field on the content item, the variation key. That field is how you address a variation in a query. There’s deliberately no tidy GUID that means “this variation.” You select one by filtering on the key.

Then indexing. Every content variation, including unpublished drafts, is indexed to Graph with a unique identifier, so they’re discoverable for previews and experiments. The docs are careful about the result. A variation isn’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’s a composite of content GUID, version status, language, and variation key. The pre-release docs show it as Guid_Status_Language_VariantKey, but that’s an example, not a contract. Address variations through _metadata (_metadata { key displayName }). Don’t string-split an id.

One default decides everything here.

By default, GraphQL queries don’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.

The opt-in is a variation argument. include is an enum (ALL | SOME | NONE), value is an array of variation keys, and includeOriginal is a boolean.

# 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: ["WinterCampaign"], includeOriginal: true }) {
    items { _metadata { key displayName } }
  }
}

The C# SDK (Optimizely.Graph.Cms.Query) mirrors this with SetVariation(...), so the discipline is the same whether you write GraphQL or build the query in C#.

That default is a safety valve, and a good one. A developer who’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’t assume a server-side default for includeOriginal. Set it. The SDK makes you anyway. SetVariation takes includeOriginal as a boolean in its direct overloads, like SetVariation(includeOriginal: true, "WinterCampaign"), and through the options object in the builder overload. Be that explicit in raw GraphQL too.

The part the launch post skipped: where this bites

This is the OMVP half, the part I’d want a client to read before they get excited.

1. Drafts are in the index, and include: ALL is a loaded gun

Unpublished variation drafts are indexed. The default protects you. The opt-in is yours to get wrong. If a delivery query reaches for ALL 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. include: ALL belongs in preview and selection logic, never as a casual “give me everything.” When you want one arm, use include: SOME and name the key.

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 ALL, with draft leakage on top wherever a preview surface gets misused.

2. Promotion is two operations, and only one is dangerous

This is the bit I see told wrong most often, so let’s be exact.

“Copy changes to Original” pushes the variation’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’t overwrite the live published version, it doesn’t auto-publish, and version history stays intact. An editor still has to publish that draft. The UI even says “Changes copied to Original!” Nothing is destroyed, so this path is recoverable. It’s a merge into a new version, not a clobber.

Promoting a variation as the default version is the sharp one. It creates a new version of the source, merges the variation’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’s separate history with it.

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’s history for good. That needs process, not just permissions, and ideally a diff in front of the editor before the merge runs.

3. Deltas are bigger than editors think

Delta storage sounds surgical. It isn’t, quite. The granularity is property-level. Change one value inside a complex property and the whole property gets copied into the delta. There’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. “I only touched the hero” is rarely true at the storage layer. That changes how much actually diverges, which changes what a promote or a re-sync does.

I didn’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 From prose to proof 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’s a ~207× blow-up. Even a plain ContentArea amplified ~37×.

4. Reference integrity has gaps

Two limitations sit right there in the docs’ own Initial Phase Limitations.

Variations currently vary only localizable properties. Non-localizable, culture-invariant properties can’t diverge. The “currently” is the docs’ word, so treat it as release-bound and re-check it on whatever version you ship.

Softlinks aren’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’s reference tracking.

The consequence is the same either way. If your governance, link-checking, or “where is this used” reporting leans on reference data, it’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’t show up where you normally look.

5. Governance scales with the surface, and most teams forget

Approval workflow covers variations if you’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’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’s just harder to see, because it doesn’t show up as nodes.

6. Index state is an environment footgun

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’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’t repeat it, so confirm it on the build you ship. I wouldn’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.

7. Pulling variation sets at scale costs you on caching

This one is a general Graph property, and it’s documented. Broad items queries trash your cache. They force it to “account for potential changes across all content,” in the docs’ words, so it invalidates constantly. Narrow single-item lookups cache cleanly and hit far more often. The queries you write to pull variations across content are exactly the broad items kind. The docs don’t tie variations to caching directly, but the query shape you reach for is the one that caches worst. On a busy site that’s a real line item, not a footnote.

“Trash your cache” is a claim with no number attached, so I attached one. The environment, the harness, and the measured hit-rate table for item vs items and SOME vs ALL are in From prose to proof below. Short version. In a deterministic cache model of a 5,000-page set, a narrow item lookup held a 99.2% hit-rate while items(limit:100) with include: ALL collapsed to 7.7%. A single base publish invalidates one item key but hundreds of broad-listing keys.

None of this is a reason to avoid the feature. It’s a reason to wrap it.

From prose to proof: two measurements

Two of the edges above are the kind of claim a reader has to take on faith. The delta is “coarser than it looks.” Broad queries “trash the cache.” 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’s tools/research/ folder and were run for this article. dotnet run -c Release reproduces the tables byte-for-byte (deterministic, seeded), and the exact build/date stamp lives in the generated RESULTS.md. One honesty note up front. The delta figures are real serialization measurements of representative property graphs, not a dump from a live store (DeltaProbe.cs is provided for that). The cache figures come from a deterministic discrete-event model of Graph’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 graph-cache.k6.js script, which isn’t run here. The full list of what these caveats mean is in the Threats to validity section below.

Measuring the delta: what “I only touched the hero” actually writes

The claim under test. 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.

Method. A CMS 13 variation version stores only the properties that diverge from its source. There’s no public “give me the raw delta bytes” 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’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.

// DeltaProbe.cs - reconstructs a variation'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) => _loader = loader;

    public IReadOnlyList<DeltaRow> Diff(ContentReference original, ContentReference variation)
    {
        var src = _loader.Get<IContent>(original);
        var var_ = _loader.Get<IContent>(variation);
        var rows = new List<DeltaRow>();

        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 => 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) => value is null
        ? Array.Empty<byte>()
        : Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value, JsonOpts));

    private static readonly JsonSerializerOptions JsonOpts =
        new() { ReferenceHandler = ReferenceHandler.IgnoreCycles, WriteIndented = false };
}

Environment. Figures come from the deterministic harness in tools/research. It builds two representative property graphs. One is a standard page with a 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 "Winter Sale""Winter Sale - Up to 40% off", an 18-byte change), and re-serializes. The DeltaProbe.cs above is the same byte-diff run against live content via IContentLoader. The harness just removes the need for a seeded site to reproduce the effect.

Result, standard page (ContentArea).

Property Type In delta? Serialized bytes
Heading PropertyString no -
MainBody PropertyXhtmlString no -
MainContentArea PropertyContentArea yes 664

The headline lives in a local block inside MainContentArea. 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× amplification.

Result, Visual Builder Experience page.

Property Type In delta? Serialized bytes
Name (metadata) no -
Composition Experience composition (Outline + Sections) yes 3,727

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’s 3,727 bytes against the 18 bytes the edit actually represents, a ~207× amplification. And that’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. “I only touched the hero” is false where it counts. Seed twenty audience variations of this one page and you’ve written ~75 KB of near-duplicate composition, and every promote or sync moves all of it.

Why it matters beyond disk. That delta is exactly what a promote merges and what Sync from default 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’t vanity. It’s the size of the blast radius.

Measuring the cache: item vs items, and the cost of include: ALL

The claim under test. Broad items queries cache badly, narrow item lookups cache well, and opting into variations with include: ALL makes it worse. A single master publish invalidates broadly.

Environment. Two layers. The hit-rate numbers below come from a deterministic discrete-event model (tools/research, run on .NET 10) of Graph’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’s dataset shape, 5,000 pages and 500 varied × 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 item lookup has one key per id, while a broad items listing fragments across filter/sort/page keys (150), with SOME (300) and ALL (600) fragmenting further. The graph-cache.k6.js script below is the live counterpart that measures p50/p99 latency against a real gateway. k6 wasn’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.

// graph-cache.k6.js — compares cache behaviour of item vs items, and SOME vs ALL.
import http from 'k6/http';
import { Trend, Rate } from 'k6/metrics';

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 ['item', 'item_some', 'items', 'items_some', 'items_all']) {
  hit[s] = new Rate(`hit_${s}`);
  lat[s] = new Trend(`lat_${s}`, true);
}

const Q = {
  item:       (id) => `{ _Content(where:{_metadata:{key:{eq:"${id}"}}}, limit:1){ items{ _metadata{ key displayName } } } }`,
  item_some:  (id) => `{ _Content(where:{_metadata:{key:{eq:"${id}"}}}, limit:1, variation:{ include: SOME, value:["WinterCampaign"], includeOriginal:true }){ items{ _metadata{ key } } } }`,
  items:      ()   => `{ _Content(limit:100){ items{ _metadata{ key displayName } } } }`,
  items_some: ()   => `{ _Content(limit:100, variation:{ include: SOME, value:["WinterCampaign"], includeOriginal:true }){ items{ _metadata{ key } } } }`,
  items_all:  ()   => `{ _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('item') ? Q[s](id) : Q[s]() });
    const res = http.post(URL, body, { headers: { 'Content-Type': 'application/json' } });
    const cached = (res.headers['X-Cache'] || res.headers['Cf-Cache-Status'] || '').toLowerCase().includes('hit');
    hit[s].add(cached);
    lat[s].add(res.timings.duration);
  }
}

Result, hit-rate by query shape (model).

Query shape Variation arg Reads Origin fetches Hit-rate
item(key) none (canonical) 200,000 1,673 99.2%
item(key) SOME ["WinterCampaign"] 200,000 1,885 99.1%
items(limit:100) none 200,000 129,679 35.2%
items(limit:100) SOME [key] 200,000 165,132 17.4%
items(limit:100) ALL + includeOriginal 200,000 184,516 7.7%

Hit-rate (modelled), drawn so the cliff is obvious:

item            ██████████████████████████████████████████████████ ≈99%
item + SOME     ██████████████████████████████████████████████████ ≈99%
items           ██████████████████                                 ≈35%
items + SOME    █████████                                          ≈17%
items + ALL     ████                                               <10%
                0%        25%        50%        75%        100%

Two readings. First, query shape dominates. A narrow item lookup is a clean, stable per-id key that effectively always hits, 1,673 origin fetches across 200,000 reads (99.2%). A broad items 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. SOME halves the broad hit-rate again (17.4%). ALL folds all 1,500 variants into the candidate space and floors it at 7.7%, about 110× the origin traffic of the narrow lookup (184,516 vs 1,673 fetches).

Result, invalidation cost of one base publish (model). Warm every key, publish a single base content inside the working set, then count the keys each shape must re-fetch from origin.

Query shape Warm keys Keys invalidated by 1 publish
item 100 1
item + SOME 100 1
items 150 150
items + SOME 300 300
items + ALL 600 600

That’s the asymmetry that matters. A base publish punches a single hole in the item cache, the published id, nobody else. The same publish poisons every broad listing key that could contain it, because the gateway can’t prove it doesn’t, so it invalidates the whole family. The variation-inventory queries you’re tempted to write (“show me every variation across the site”) are precisely this broad items shape. That’s why the Auditor’s grid resolves variation keys through narrow per-item lookups and batches the deliverability probe one round-trip per content id, instead of one broad items(variation: ALL) sweep. The measurement drove the design.

Repeatability. The whole harness ships in the add-on’s tools/research/ folder: DeltaExperiment.cs, CacheExperiment.cs, the graph-cache.k6.js live script, and the generated RESULTS.md. dotnet run -c Release reproduces both tables byte-for-byte (the run is deterministic, seeded 1337). Point DeltaProbe.cs 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 item and items + ALL, and the property-level delta amplification on Experience content, do not.

Threats to validity

Both experiments confirm the direction of the claims. Be precise about what they don’t prove.

  • Delta is a serialization model, not a live store dump. 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 DeltaProbe.cs against real content for authoritative figures.
  • Delta magnitude scales with composition size. The 207× 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.
  • The ContentArea result assumes local/inline blocks. If the area holds shared block references, editing the referenced block changes a different content item, not the page’s delta. The Experience composition embeds its blocks, so that case holds robustly.
  • Cache numbers are a model, not a live-gateway measurement. 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 graph-cache.k6.js against a real gateway settles the magnitudes.
  • No latency was measured. Every p50/p99 statement is deferred to the live k6 run. None is asserted here.

None of these soften the practical takeaway. Narrow lookups and bounded variation scoping win. Coarse deltas and include: ALL lose. But they mark exactly where “measured” ends and “modelled” begins.

Live spot-check. 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 variation: { include: ALL | SOME | NONE, includeOriginal } argument. The gateway caches with Cache-Control: public, max-age=86400 and reports CF-Cache-Status: HIT/MISS. A cache-miss include: ALL query was the costliest at the origin (p50 ~506 ms vs ~406 ms for a narrow item miss). What that source couldn’t validate (it held 24 items and zero variations, and the key can’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.

Where the add-on comes in: PiNo Labs Variations Auditor

Most of the risks above aren’t bugs. They’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’t have opinions about how your team should use it. That gap is what a thin add-on fills.

In our Foundation solution we packaged the governance into a small CMS 13 add-on, PiNo.Labs.VariationsAuditor, 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 IConfigurableModule, a ProtectedModuleOptions self-registration, an /api/auditor/* REST surface, a shell menu entry, and a React UI embedded in the assembly and served straight from the DLL, with no wwwroot 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’s missing.

A fair question before the code. Every platform seam the snippets touch (shell-module and menu registration, IConfigurableModule/ProtectedModuleOptions, the IContentEvents.PublishedContent hook, the versioning APIs) sits inside CMS 13’s breaking-change surface. They’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’s what it does, mapped to the edges above.

It starts from the one inconvenient truth the platform hands you. There’s no in-process, site-wide API for “all content that has variations.” A CMS 13 content variation is just a content version carrying a non-empty IVersionable.Variation key, and a variation’s identity is a tuple of content, language, and variation key, not a GUID.

// A CMS 13 "content variation" is a version with a non-empty IVersionable.Variation key.
var variants = versionRepository
    .List(new VersionFilter { ContentLink = contentLink }, 0, max, out _)
    .Where(v => !string.IsNullOrEmpty(v.Variation));     // empty key == language baseline, not a variant

Discovery uses Graph to enumerate the site, then reads the authoritative variation keys from IContentVersionRepository. If Graph is down or hasn’t finished indexing, it falls back to an in-process content-tree walk. The grid works on day one, indexed or not.

A pre-promotion diff, so promotion is a decision and not a reflex (edges #2 and #3). 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’t warn about. Promoting a variation whose source has moved on since.

// Stale-conflict guard: refuse to silently overwrite newer master changes on promote.
var stale = divergence.GetStaleProperties(target);
if (action == BulkActionType.Promote && stale.Count > 0 && !cmd.ForceOverwriteStale)
{
    return Blocked(
        "CRITICAL REVERSION RISK: promoting would overwrite newer changes on the original for: " +
        string.Join(", ", stale) + ". Re-run with ForceOverwriteStale to proceed.");
}

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’s delta model intact instead of flattening master-only properties. We pulled an earlier “replace everything” mode because it broke exactly that model. The same dry-run, lock, and status gate guards Unpublish and Delete. The inverse action, “Sync from default,” lets an editor pull the original’s current value back into a stale variation, which is the safe fix for edge #3.

A site-wide orphan and sprawl report, so cleanup actually happens (edge #5). 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.

A deliverability probe that turns silent index failures loud (edges #4 and #6). This is the canary for “my variation should be live but isn’t in Graph.” 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 “where are my variations, and are they being served” view the reference system can’t give you. It doesn’t lie the other way either. If the Graph client isn’t registered at all, the probe returns deliverable rather than painting every variant red.

// Optional Graph client, never required. No AddGraphContentClient()? Degrade, don't crash.
_graph = serviceProvider.GetService<IGraphContentClient>();
...
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

Proactive drift notifications, so editors come back when something changes (edge #5 again). A StaleDriftDetector subscribes to IContentEvents.PublishedContent. When a default publishes, it recomputes its variants’ staleness and tells the owner of each newly stale variation. The default just moved, your audience is now on yesterday’s content for these three properties. It’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 INotificationChannels (webhook to Slack/Teams, SMTP email, log), all off until the host opts in.

public void Initialize(InitializationEngine context)
    => context.Locate.ContentEvents().PublishedContent += OnDefaultPublished; // drift stops being silent

And the one guardrail that belongs at the delivery layer, not in the editor (edge #1). The auditor governs the editorial and estate side, which is the side it can see. The include: ALL 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 ALL 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’ve covered what’s in the estate and what reaches the visitor.

The point isn’t the code. It’s the posture. The platform ships the engine and a sensible default. You ship the seatbelts that fit your team’s risk tolerance. That’s what an add-on is for.

The full source of the add-on is available on GitHub at https://github.com/pino-labs/pino-labs-variations-auditor.

The take

A year from now, Content Variations won’t be what people remember about CMS 13. Visual Builder and the headless story will take that slot. But it’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.

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’ve always wanted, which is to experiment on a page without turning the content tree into an archaeological dig.

Quiet hero. Sharp edges. Worth it..

Further reading

Jun 08, 2026

Comments

Please login to comment.
Latest blogs
Optimizely CMS 13 Upgrade Overview

Wondering how to upgrade from Optimizely CMS 12 to 13? Not sure where to start – you are not alone.  Here is a quick overview... The post Optimizel...

Madhu | Jun 8, 2026 |

Optimizely Developers The Netherlands Meetup 2026

  I'm excited to not only co-host the upcoming  Optimizely Developers (The Netherlands) Meetup on my home turf at iO  in Den Bosch with Ehsan Ahzar...

WilliamP | Jun 5, 2026

Automatic assign of content reference properties on content creation

A long time ago I was tired of configuring setting properties on a settings page when deploying new functionality that needed global settings, so I...

Per Nergård (MVP) | Jun 5, 2026