<?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>Blogs</title> <link>https://world.optimizely.com/blogs/</link><description></description><ttl>60</ttl><generator>Optimizely World</generator><item> <title>Personalisation in CMS 13 when you go headless: variations in the CMS, decisions in Experimentation</title>            <link>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2026/9/personalisation-in-cms-13-when-you-go-headless-variations-in-the-cms-decisions-in-experimentation/</link>            <description>&lt;p&gt;Back in February I wrote about &lt;a style=&quot;color: #008a3e;&quot; href=&quot;/link/cb8190a3782840fdacf589c9b5b38c0a.aspx&quot;&gt;personalisation in CMS 13 using Audiences&lt;/a&gt;. Everything in that post still holds, with one condition I should have made louder: it only works when the CMS renders the page. The moment your front end is a separate application reading content from Optimizely Graph, Audiences stop doing anything.&lt;/p&gt;
&lt;p&gt;This post covers why that is, and how I would approach personalisation on a headless (or hybrid) CMS 13 build instead. The content still lives in the CMS, but the decision about who sees which content moves to Optimizely&#39;s experimentation and personalisation products.&lt;/p&gt;
&lt;h2&gt;Why Audiences do not work on a headless build&lt;/h2&gt;
&lt;p&gt;Audiences (Visitor Groups, as they were) are evaluated inside the CMS process, on each request. When a content area renders, the CMS looks at the current visitor (their referrer, the pages they have viewed in this session, their geography and so on), checks each item&#39;s audience settings, and leaves out the items that do not match. The personalised HTML is the output of that render.&lt;/p&gt;
&lt;p&gt;&lt;img style=&quot;max-width: 100%; height: auto;&quot; src=&quot;/link/59c834ced2c0416b9464b6f0f85c2cdb.aspx&quot; alt=&quot;Personalized Group in a CMS 13 content area, with the Alloy Plan teaser shown only to the Alloy Track for free audience&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Audiences are evaluated when the CMS renders the content area, which is why they only apply to in-process sites.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;On a headless build there is no render in the CMS. Content is published and synchronised into Optimizely Graph once, for everybody, and your Next.js (or other) front end queries Graph when a visitor arrives. Graph never sees the visitor, and the CMS is not in the request path at all, so there is nothing to evaluate the audience against.&lt;/p&gt;
&lt;p&gt;Optimizely&#39;s own CMS 13 documentation is explicit about this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;Audiences work only on traditional (in-process) sites, where CMS manages both content authoring and frontend rendering.&quot;&lt;br /&gt;(source: &lt;a style=&quot;color: #008a3e;&quot; href=&quot;https://docs.developers.optimizely.com/content-management-system/v13.0.0-CMS/docs/audiences&quot;&gt;Create audiences for personalization&lt;/a&gt;)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The CMS 13 technical Q&amp;amp;A adds that Visitor Groups and Audiences &quot;are not indexed to Graph&quot;, and recommends &quot;the new Variations support for content and Experimentation for personalization&quot; for Graph-based delivery (source: &lt;a style=&quot;color: #008a3e;&quot; href=&quot;https://docs.developers.optimizely.com/content-management-system/v13.0.0-CMS/docs/faqs-for-cms-13&quot;&gt;CMS 13 technical webinar Q&amp;amp;A&lt;/a&gt;). The &quot;View as audience&quot; preview is also unavailable on decoupled front ends.&lt;/p&gt;
&lt;p&gt;So if your build is headed MVC, carry on with Audiences; the February post still applies. If it is headless or hybrid, read on.&lt;/p&gt;
&lt;h2&gt;The new split: content in the CMS, the decision in Experimentation&lt;/h2&gt;
&lt;p&gt;The recommended model separates two jobs that Audiences used to do together.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The content.&lt;/strong&gt; Editors author each version of a page in the CMS as a &lt;em&gt;content variation&lt;/em&gt;. It gets drafted, reviewed, approved and published like any other content, and it is indexed to Graph.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The decision.&lt;/strong&gt; An experimentation or personalisation engine decides which variation a given visitor should see, and the front end fetches that variation from Graph.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The two are joined by a single string: the variation name. If the engine says &quot;WinterCampaign&quot;, the front end asks Graph for the &quot;WinterCampaign&quot; variation of the page. If that variation does not exist, it falls back to the original.&lt;/p&gt;
&lt;p&gt;&lt;img style=&quot;max-width: 100%; height: auto;&quot; src=&quot;/link/6c4802448de74037af97b744850ac297.aspx&quot; alt=&quot;Diagram of the flow: editors create content variations in CMS 13, which sync to Optimizely Graph. On each visit the front end asks Experimentation which variation to show, then queries Graph for it with the original as the fallback&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I think this is a better model than it first appears. Content stays in one place, under the CMS&#39;s workflow and version history, rather than being pasted into a testing tool&#39;s visual editor. The engine that decides is also the one that measures, so every personalised experience can be tested against the original.&lt;/p&gt;
&lt;h2&gt;Content variations in CMS 13&lt;/h2&gt;
&lt;p&gt;A content variation is a second (or third, or tenth) published version of the same content item, in the same language. Each variation has its own version history and publishing lifecycle, and only stores the properties you actually change (source: &lt;a style=&quot;color: #008a3e;&quot; href=&quot;https://docs.developers.optimizely.com/content-management-system/v13.0.0-CMS/docs/create-content-variations&quot;&gt;Create content variations in CMS 13&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;Creating one from the editor:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Select the page in the content tree.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Variations &amp;gt; Add variation&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Give it a name, for example &lt;span style=&quot;font-family: SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; background-color: #eef1f4; color: #1f2328; padding: 1px 5px; border-radius: 3px;&quot;&gt;WinterCampaign&lt;/span&gt;. It cannot start with a number or contain spaces.&lt;/li&gt;
&lt;li&gt;Select &lt;strong&gt;Add variation&lt;/strong&gt;. The variation opens in Visual Builder and appears in the &lt;strong&gt;Variations&lt;/strong&gt; dropdown.&lt;/li&gt;
&lt;li&gt;Make your changes, then &lt;strong&gt;Publish &amp;gt; Publish changes&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;You can also copy a new variation from an existing one using &lt;strong&gt;Copy from&lt;/strong&gt;, and if a variation wins, &lt;strong&gt;Copy changes to Original&lt;/strong&gt; promotes it to become the new default.&lt;/p&gt;
&lt;p&gt;&lt;img style=&quot;max-width: 100%; height: auto;&quot; src=&quot;/link/b51065d16eac4f6ba2dc5775862ef3f7.aspx&quot; alt=&quot;Variations dropdown in the CMS 13 editor showing Original and Add variation&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img style=&quot;max-width: 100%; height: auto;&quot; src=&quot;/link/9e930d504ae2457183f8251e2329532a.aspx&quot; alt=&quot;Add variation dialog with the name WinterCampaign entered and Copy from set to Original&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img style=&quot;max-width: 100%; height: auto;&quot; src=&quot;/link/9caefb04236d4280894c5dbf1f180d68.aspx&quot; alt=&quot;WinterCampaign selected in the Variations dropdown, with a winter offer intro in the preview&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img style=&quot;max-width: 100%; height: auto;&quot; src=&quot;/link/9443396a0e594db09210707013e9f418.aspx&quot; alt=&quot;Publish menu on the WinterCampaign variation, including Copy changes to Original&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Two things worth knowing before your editors start:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Name variations for what they are, and treat the name as a contract.&lt;/strong&gt; The name is what the experimentation side will ask for, and it is case sensitive. Rename a variation in the CMS and the front end will quietly serve the original to everyone while the experiment keeps counting visitors.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check which properties can vary on your version.&lt;/strong&gt; At the time of writing, variations only vary localisable properties. Treat that as release-specific and re-check it on the version you ship.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Fetching a variation from Graph&lt;/h3&gt;
&lt;p&gt;By default, Graph queries only return original content, so existing queries are unaffected. To ask for a variation, add the &lt;span style=&quot;font-family: SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; background-color: #eef1f4; color: #1f2328; padding: 1px 5px; border-radius: 3px;&quot;&gt;variation&lt;/span&gt; argument and keep the original in the response as the fallback (source: &lt;a style=&quot;color: #008a3e;&quot; href=&quot;https://docs.developers.optimizely.com/platform-optimizely/docs/content-variation&quot;&gt;Query content variations in Optimizely Graph&lt;/a&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;query LandingPage($variation: [String!]) {
  _Content(
    where: { _metadata: { url: { default: { eq: &quot;/winter-offers/&quot; } } } }
    variation: { include: SOME, value: $variation, includeOriginal: true }
  ) {
    items {
      _metadata { key displayName variation }
      ... on LandingPage { Heading MainBody { html } }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const requested = items.find((i) =&amp;gt; i._metadata?.variation === variationKey);
const original = items.find((i) =&amp;gt; !i._metadata?.variation);
const page = requested ?? original;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img style=&quot;max-width: 100%; height: auto;&quot; src=&quot;/link/cd16daca56c64127b66a4fed7da7ae21.aspx&quot; alt=&quot;Optimizely Graph query requesting the WinterCampaign variation, and the response returning the variation alongside the original&quot; /&gt;&lt;/p&gt;
&lt;p&gt;That fallback is what makes the whole approach safe. If the engine is switched off, returns nothing, or asks for a variation that does not exist, the visitor gets the original page.&lt;/p&gt;
&lt;h2&gt;Route 1: Feature Experimentation (server-side, the native route)&lt;/h2&gt;
&lt;p&gt;This is the route Optimizely documents today, and the one I would reach for first on a headless build (source: &lt;a style=&quot;color: #008a3e;&quot; href=&quot;https://docs.developers.optimizely.com/feature-experimentation/docs/configure-optimizely-cms-saas&quot;&gt;Configure the Optimizely CMS (SaaS) integration&lt;/a&gt;; Piotr Nowak&#39;s &lt;a style=&quot;color: #008a3e;&quot; href=&quot;/link/06d8f538fc754cb9b4362f2b31071f31.aspx&quot;&gt;Unlock Experimentation with Content Variations in CMS 13&lt;/a&gt; walks through the same pattern for CMS 13 with both an MVC and a Next.js head).&lt;/p&gt;
&lt;p&gt;In Feature Experimentation:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create a flag, for example &lt;span style=&quot;font-family: SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; background-color: #eef1f4; color: #1f2328; padding: 1px 5px; border-radius: 3px;&quot;&gt;winter_landing_page&lt;/span&gt;.&lt;/li&gt;
&lt;li&gt;Add variations whose keys match the CMS variation names exactly (&lt;span style=&quot;font-family: SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; background-color: #eef1f4; color: #1f2328; padding: 1px 5px; border-radius: 3px;&quot;&gt;WinterCampaign&lt;/span&gt;, &lt;span style=&quot;font-family: SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; background-color: #eef1f4; color: #1f2328; padding: 1px 5px; border-radius: 3px;&quot;&gt;SummerCampaign&lt;/span&gt;). The documented SaaS integration also adds a string variable carrying the name, which is a good idea because it lets you change the mapping without renaming the variation.&lt;/li&gt;
&lt;li&gt;Add a rule.
&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;A/B test&lt;/strong&gt; rule splits traffic across the variations and measures them against a metric.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;targeted delivery&lt;/strong&gt; rule with an &lt;strong&gt;audience&lt;/strong&gt; serves one variation to everyone who matches, for example visitors in the UK, logged-in members, or a segment you pass in as an attribute. This is where personalisation happens.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;In the front end, decide on the server and fetch the matching variation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { createInstance, OptimizelyDecideOption } from &#39;@optimizely/optimizely-sdk&#39;;

const optimizely = createInstance({
  sdkKey: process.env.OPTIMIZELY_FX_SDK_KEY!,
  datafileOptions: { autoUpdate: true, updateInterval: 60_000 },
});

export async function getVariationKey(visitorId: string, attributes: Record&amp;lt;string, unknown&amp;gt;) {
  await optimizely.onReady();
  const user = optimizely.createUserContext(visitorId, attributes);
  const decision = user?.decide(&#39;winter_landing_page&#39;, [OptimizelyDecideOption.INCLUDE_REASONS]);
  return decision?.enabled ? decision.variationKey : null;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few things I would build in from the start:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A stable visitor id.&lt;/strong&gt; Bucketing hashes on the user id, so anonymous visitors need a first-party cookie set in middleware, or they will flip between variations.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep the datafile fresh.&lt;/strong&gt; Without &lt;span style=&quot;font-family: SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; background-color: #eef1f4; color: #1f2328; padding: 1px 5px; border-radius: 3px;&quot;&gt;autoUpdate&lt;/span&gt;, the JavaScript SDK fetches the datafile once, so rule changes in the dashboard do not reach a long-running server.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Caching.&lt;/strong&gt; A page that varies by visitor cannot be served from one shared cache entry. Either mark decided responses as uncacheable, or cache per variation (the variation name makes a sensible cache key when the number of variations is small).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Attributes only come from you.&lt;/strong&gt; Feature Experimentation audiences evaluate the attributes your code passes in. If you want to target on a CRM segment or a logged-in state, the front end has to supply it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Because the decision happens on the server, there is no flicker and the personalised content is in the first HTML response.&lt;/p&gt;
&lt;h2&gt;Route 2: Web Experimentation and Personalization (client-side)&lt;/h2&gt;
&lt;p&gt;The other half of Optimizely&#39;s engine is Web Experimentation and its sibling, Optimizely Personalization. Personalization adds &lt;em&gt;campaigns&lt;/em&gt; made of &lt;em&gt;experiences&lt;/em&gt;, each targeted at an audience, and can use real-time segments from Optimizely Data Platform where you have it (source: &lt;a style=&quot;color: #008a3e;&quot; href=&quot;https://support.optimizely.com/hc/en-us/articles/27294865902733-Optimizely-Personalization-overview&quot;&gt;Optimizely Personalization overview&lt;/a&gt;). Marketers own these audiences and campaigns in the Optimizely UI, which is often exactly what a marketing team wants.&lt;/p&gt;
&lt;p&gt;The trap here is the visual editor. It makes it easy to change a heading or swap an image directly in the browser, and when you do that the personalised content lives in Web Experimentation, not in the CMS. It is unversioned by the CMS, invisible to editors, and it breaks the moment the page markup changes.&lt;/p&gt;
&lt;p&gt;The better pattern keeps the content in the CMS and uses the campaign only to choose the variation:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Name each Web Experimentation variation or Personalization experience after the CMS variation it should show.&lt;/li&gt;
&lt;li&gt;In the front end, read the visitor&#39;s active decisions from the snippet&#39;s state API and fetch the matching CMS variation from Graph.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;function getCmsVariationName(): string | null {
  const state = (window as any).optimizely?.get?.(&#39;state&#39;);
  if (!state) return null;
  const active = state.getCampaignStates({ isActive: true });
  for (const campaign of Object.values&amp;lt;any&amp;gt;(active)) {
    const name = campaign.variation?.name;
    if (name &amp;amp;&amp;amp; /^[A-Za-z][A-Za-z0-9]*$/.test(name)) return name;
  }
  return null;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span style=&quot;font-family: SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; background-color: #eef1f4; color: #1f2328; padding: 1px 5px; border-radius: 3px;&quot;&gt;getCampaignStates&lt;/span&gt; returns both experiments and personalisation campaigns (source: &lt;a style=&quot;color: #008a3e;&quot; href=&quot;https://docs.developers.optimizely.com/web-experimentation/reference/state&quot;&gt;Get state&lt;/a&gt;). Check the returned shape against your own snippet version before relying on it.&lt;/p&gt;
&lt;p&gt;This is a pattern rather than an out-of-the-box integration. Optimizely documents the Feature Experimentation route; I have not found a documented Web Experimentation equivalent, so treat this as something you build and own. The trade-offs are the usual client-side ones: the decision happens after the page loads, so either the personalised region is fetched late (reserve its space to avoid layout shift) or you hide it until the decision arrives. I would keep personalised regions out of the largest above-the-fold element for that reason.&lt;/p&gt;
&lt;h2&gt;Which route should you use?&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; margin: 16px 0 24px 0; border: 1px solid #d0d7de; font-size: 15px;&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;background-color: #0b1f3a; color: #ffffff; font-weight: bold; text-align: left; vertical-align: top; padding: 10px 12px; border: 1px solid #0b1f3a;&quot;&gt;&amp;nbsp;&lt;/th&gt;
&lt;th style=&quot;background-color: #0b1f3a; color: #ffffff; font-weight: bold; text-align: left; vertical-align: top; padding: 10px 12px; border: 1px solid #0b1f3a;&quot;&gt;Feature Experimentation&lt;/th&gt;
&lt;th style=&quot;background-color: #0b1f3a; color: #ffffff; font-weight: bold; text-align: left; vertical-align: top; padding: 10px 12px; border: 1px solid #0b1f3a;&quot;&gt;Web Experimentation / Personalization&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #ffffff; font-weight: bold; width: 28%;&quot;&gt;Where the decision happens&lt;/td&gt;
&lt;td style=&quot;padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #ffffff; width: 36%;&quot;&gt;Your server or edge&lt;/td&gt;
&lt;td style=&quot;padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #ffffff; width: 36%;&quot;&gt;The visitor&#39;s browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #f6f8fa; font-weight: bold; width: 28%;&quot;&gt;Flicker and layout shift&lt;/td&gt;
&lt;td style=&quot;padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #f6f8fa; width: 36%;&quot;&gt;None&lt;/td&gt;
&lt;td style=&quot;padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #f6f8fa; width: 36%;&quot;&gt;Needs designing for&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #ffffff; font-weight: bold; width: 28%;&quot;&gt;Who owns audiences&lt;/td&gt;
&lt;td style=&quot;padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #ffffff; width: 36%;&quot;&gt;Developers pass attributes; audiences built in FX&lt;/td&gt;
&lt;td style=&quot;padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #ffffff; width: 36%;&quot;&gt;Marketers, in the Optimizely UI, including ODP real-time segments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #f6f8fa; font-weight: bold; width: 28%;&quot;&gt;Content lives in&lt;/td&gt;
&lt;td style=&quot;padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #f6f8fa; width: 36%;&quot;&gt;The CMS&lt;/td&gt;
&lt;td style=&quot;padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #f6f8fa; width: 36%;&quot;&gt;The CMS, if you use the pattern above&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #ffffff; font-weight: bold; width: 28%;&quot;&gt;Documented by Optimizely for CMS variations&lt;/td&gt;
&lt;td style=&quot;padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #ffffff; width: 36%;&quot;&gt;Yes&lt;/td&gt;
&lt;td style=&quot;padding: 10px 12px; border: 1px solid #d0d7de; vertical-align: top; text-align: left; background-color: #ffffff; width: 36%;&quot;&gt;Not yet; build it as a pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;My default on a headless or hybrid CMS 13 build is Feature Experimentation for anything above the fold or on key journeys, with Personalization campaigns where marketers need to own and change audiences without a developer. Both can sit on the same content variations, because the only thing either needs from the CMS is the name.&lt;/p&gt;
&lt;h2&gt;What about headed sites?&lt;/h2&gt;
&lt;p&gt;Nothing changes. Audiences still work on in-process CMS 13 sites and remain the simplest option for rules-based personalisation. Content variations work there too, so a headed site can use the same Feature Experimentation pattern with the .NET SDK and &lt;span style=&quot;font-family: SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.9em; background-color: #eef1f4; color: #1f2328; padding: 1px 5px; border-radius: 3px;&quot;&gt;IContentLoader&lt;/span&gt; when you want proper testing rather than show-and-hide rules.&lt;/p&gt;
&lt;p&gt;Optimizely has said deeper integration between the CMS and Experimentation is on the roadmap, without a date. I will update this post if that changes the picture.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2026/9/personalisation-in-cms-13-when-you-go-headless-variations-in-the-cms-decisions-in-experimentation/</guid>            <pubDate>Thu, 24 Sep 2026 13:48:00 GMT</pubDate>           <category>Blog post</category></item><item> <title>Optimizely Forms: 4.7.0 Too many concurrent connections</title>            <link>https://www.gulla.net/en/blog/optimizely-forms-4.7.0-too-many-concurrent-connections/</link>            <description>Optimizely Forms are eating your emails! How to make it stop!</description>            <guid>https://www.gulla.net/en/blog/optimizely-forms-4.7.0-too-many-concurrent-connections/</guid>            <pubDate>Thu, 24 Sep 2026 11:19:24 GMT</pubDate>           <category>Blog post</category></item><item> <title>Optimizely CMS 13.2: Cannot find an authentication provider for &#39;ActiveDirectoryManagedIdentity&#39;</title>            <link>https://www.gulla.net/en/blog/optimizely-cms-13_2-cannot-find-an-authentication-provider-for-activedirectorymanagedidentity/</link>            <description>CMS 13.2 upgraded SqlClient to 7, and Managed Identity stopped working in Azure until I added one NuGet package.</description>            <guid>https://www.gulla.net/en/blog/optimizely-cms-13_2-cannot-find-an-authentication-provider-for-activedirectorymanagedidentity/</guid>            <pubDate>Wed, 23 Sep 2026 09:03:12 GMT</pubDate>           <category>Blog post</category></item><item> <title>Composition Over Inheritance: Why Your Content Contract Is Not a Base Class</title>            <link>https://world.optimizely.com/blogs/vipin-banka--learnings--insights/dates/2026/9/composition-over-inheritance-why-your-content-contract-is-not-a-base-class/</link>            <description>&lt;p&gt;&lt;em&gt;Inside the structural conformance pattern in SaaS CMS &amp;mdash; from server-side inheritance to pipeline governance.&lt;/em&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;A contract in Optimizely SaaS CMS is rarely what developers expect on day one.&lt;/p&gt;
&lt;p&gt;Create a contract called &lt;strong&gt;Categorizable&lt;/strong&gt; with &lt;strong&gt;Category&lt;/strong&gt; and &lt;strong&gt;Tags&lt;/strong&gt; properties, apply it to a content type, and open that type&#39;s property configuration. In conventional class-based inheritance, you would expect an inherited property to be an immutable field handed down from a parent class. Instead, the fields sit directly on the content type, with their own display names, help text, and local configurations.&lt;/p&gt;
&lt;p&gt;Think of it like a job description rather than a base class. A job description specifies the qualifications a candidate must bring; it does not hand them those qualifications. The candidate arrives already qualified, and the job description simply verifies the match.&lt;/p&gt;
&lt;p&gt;Contracts operate the same way. They do not supply properties to your content types; your content types declare their own properties, and the contract validates that they conform to the required shape.&lt;/p&gt;
&lt;p&gt;Two quick checks confirm this:&lt;/p&gt;
&lt;ol class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;The official documentation notes that properties must exist on the content type &lt;em&gt;before&lt;/em&gt; applying the contract.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;The content type REST payload keeps &lt;strong&gt;isContract&lt;/strong&gt;, &lt;strong&gt;contracts&lt;/strong&gt;, and &lt;strong&gt;properties&lt;/strong&gt; as completely separate fields.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That single distinction &amp;mdash; validator, not supplier &amp;mdash; changes how you design your content model, how your team coordinates deployments, and what your API can deliver.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The compiler was your guardian&lt;/h2&gt;
&lt;p&gt;Historically, enterprise content modeling in server-side content management systems followed a class-based inheritance pattern:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-csharp&quot;&gt;[ContentType(DisplayName = &quot;Article Page&quot;)]
public class ArticlePage : SitePageData
{
    public virtual string Category { get; set; }

    public virtual IList&amp;lt;string&amp;gt; Tags { get; set; }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This model had genuine elegance. You declared a hierarchy, and the platform did the rest. At application startup, the framework walked your class tree through reflection, synchronised it against the database, and refused to proceed if something did not line up. Change a property type where data already existed and you were told immediately &amp;mdash; before a single editor saw a broken field.&lt;/p&gt;
&lt;p&gt;The enforcement was &lt;strong&gt;automatic, unconditional, and happened before content existed&lt;/strong&gt;. You did not have to remember to check. A compiler and a startup sync did the remembering for you.&lt;/p&gt;
&lt;p&gt;The trade-offs of deep inheritance hierarchies are well known: fragile base classes cause parent changes to ripple unpredictably into descendants, single-inheritance limits composition across multiple domains, and deep template trees become difficult to reason about and re-index.&lt;/p&gt;
&lt;p&gt;Yet class inheritance solved a genuine governance problem, and it solved it automatically before content existed. That is the property worth remembering as we examine what replaced it.&lt;/p&gt;
&lt;p&gt;There was one condition attached to all of it: &lt;strong&gt;it worked because a single runtime owned the entire model.&lt;/strong&gt; That condition is the thing that changed.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Two ways to satisfy GraphQL, and where each one puts the inheritance&lt;/h2&gt;
&lt;p&gt;Something shifted underneath the model, and it is worth separating two layers that often get discussed as one.&lt;/p&gt;
&lt;h3&gt;The runtime left&lt;/h3&gt;
&lt;p&gt;Optimizely SaaS CMS operates as a multi-tenant cloud architecture. Rather than executing custom server-side binaries or reflection passes during application startup, content types are declared as structured JSON schemas over a REST API. Declarative schemas replace compiled application code.&lt;/p&gt;
&lt;p&gt;Remove the server-side runtime and you remove the mechanism that made compiled class inheritance enforceable.&lt;/p&gt;
&lt;h3&gt;GraphQL has no inheritance, and that constrains every platform equally&lt;/h3&gt;
&lt;p&gt;The GraphQL specification has no concept of class inheritance. It offers exactly two tools for polymorphism: &lt;strong&gt;interface&lt;/strong&gt; and &lt;strong&gt;union&lt;/strong&gt;. A GraphQL interface works structurally &amp;mdash; every concrete type implementing an interface must &lt;em&gt;itself&lt;/em&gt; declare every field that interface defines. The interface does not hand fields down. It describes a shape and checks that implementers match it.&lt;/p&gt;
&lt;p&gt;This is a property of the specification, not a platform decision. Any content platform delivering through GraphQL has to project its content model into structural interfaces at the API boundary, because that is the only shape the query layer can execute.&lt;/p&gt;
&lt;h3&gt;But that constraint applies to delivery, not to authoring&lt;/h3&gt;
&lt;p&gt;Here is the part worth being precise about, because it is easy to over-generalise.&lt;/p&gt;
&lt;p&gt;The GraphQL constraint governs what the &lt;strong&gt;delivery schema&lt;/strong&gt; looks like. It says nothing about how a platform models content internally. That leaves two coherent designs, and the wider content management category contains working examples of both.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Design one: keep inheritance in the authoring model, project it at the edge.&lt;/strong&gt; The CMS retains a template or class hierarchy. Fields are declared once on a parent and cascade to descendants. A pipeline then flattens that hierarchy into structural GraphQL interfaces when content is indexed for delivery. Authors and developers get single-point field definition and automatic propagation. The cost is a translation step between two different schema representations, and inheritance coupling in the authoring model.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Design two: model structurally from the start.&lt;/strong&gt; Content types are flat. Each declares its own properties. Shared shapes are expressed as contracts that validate conformance rather than supply fields. The authoring schema and the delivery schema are the same shape, so nothing needs translating. The cost is that shared definitions repeat across implementers, and propagation becomes an explicit, coordinated act rather than an automatic one.&lt;/p&gt;
&lt;p&gt;Neither is the modern one and neither is the dated one. They optimise for different things &amp;mdash; the first for authoring convenience and single-point definition, the second for schema alignment and reach across sources.&lt;/p&gt;
&lt;h3&gt;Structural contracts in SaaS CMS&lt;/h3&gt;
&lt;p&gt;Optimizely SaaS CMS operates on this structural model. Content types declare their own properties directly, and contracts validate their conformance for Optimizely Graph.&lt;/p&gt;
&lt;p&gt;Because the content type schema and the delivery schema share the same structural shape, there is no translation layer between CMS models and GraphQL interfaces. A direct consequence of this architectural alignment is that the exact same contract can govern content across boundaries &amp;mdash; whether it originates natively in the CMS or is connected from an external source.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;A contract is not a base class. Here is what it actually is.&lt;/h2&gt;
&lt;p&gt;A contract is a &lt;strong&gt;shape validator and a Graph schema generator&lt;/strong&gt;. It does not supply properties to your content type. Your content type supplies its own, and the contract validates that they match.&lt;/p&gt;
&lt;p&gt;Side by side, the two models look like this:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;CLASS INHERITANCE (Runtime-Enforced Base Class)
Vertical hierarchy: Subclasses descend from a rigid base class

       +------------------------+
       |      SitePageData      |  &amp;lt;-- Base class defines Category, Tags
       +-----------+------------+
                   |  Inherits (Framework reflection at startup)
         +---------+---------+
         |                   |
         v                   v
  +--------------+    +--------------+
  | ArticlePage  |    |  EventPage   |  &amp;lt;-- Bound to single application runtime
  |  + Excerpt   |    |  + StartDate |      Cannot span across independent systems
  +--------------+    +--------------+


STRUCTURAL COMPOSITION (CMS SaaS / Optimizely Graph)
Horizontal conformance: Independent traits validate flat, multi-source entities

  +--------------------------------------------------------------+
  |                    Categorizable Contract                    |
  |              (Defines required shape in Graph)               |
  +---------------+-------------------------------+--------------+
                  |                               |
        Validates | Shape               Validates | Shape
                  v                               v
  +------------------------------+ +------------------------------+
  |         ArticlePage          | |         ExternalAsset        |
  |                              | |                              |
  | * Category (Editorial Topic) | | * Category (Brand Theme)     |
  | * Tags                       | | * Tags                       |
  | * Excerpt                    | | * AssetUrl                   |
  +------------------------------+ +------------------------------+
        [Native CMS Content]             [External Connected Source]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In this model, content types remain flat entities. Contracts act as shape validators rather than parents, and conformance can span across native CMS pages and external connected assets alike.&lt;/p&gt;
&lt;p&gt;In Optimizely Graph, each contract registers as a native GraphQL interface. Implementing content types satisfy that interface by declaring the required fields on their own schema, while remaining completely independent entities.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;What the structural model genuinely unlocks&lt;/h2&gt;
&lt;p&gt;It would be a shame to treat this purely as a constraint to work around. The structural model enables several things that classical inheritance could not, and a few of them are genuinely significant.&lt;/p&gt;
&lt;h3&gt;One query across many types&lt;/h3&gt;
&lt;p&gt;Because the contract becomes a real GraphQL interface, you can query across every implementing type in a single request, and reach for type-specific fields where you need them:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-graphql&quot;&gt;query {
  Categorizable(where: { Category: { eq: &quot;Meetup&quot; } }) {
    items {
      _itemMetadata {
        key
        displayName
      }
      Category
      Tags
      ... on ArticlePage {
        Excerpt
      }
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No merging results from three separate queries. No client-side reconciliation.&lt;/p&gt;
&lt;h3&gt;Components that do not care what page they are on&lt;/h3&gt;
&lt;p&gt;A front-end component can bind to the contract rather than to any concrete type. A category pill, a tag list, a card renderer &amp;mdash; each written once against &lt;strong&gt;Categorizable&lt;/strong&gt;, working unchanged when a fourth and fifth content type start implementing it later.&lt;/p&gt;
&lt;h3&gt;Horizontal growth&lt;/h3&gt;
&lt;p&gt;New content types do not disturb existing ones. There is no parent to modify, so there is no cascade to manage. The model grows sideways rather than downwards.&lt;/p&gt;
&lt;h3&gt;Composing several traits at once&lt;/h3&gt;
&lt;p&gt;Single-inheritance models force a choice. A component that needs headline behaviour, section placement rules, and tab display options has one parent slot to work with, so teams either build a deep chain where each layer adds a concern, or pick one parent and re-declare the rest by hand.&lt;/p&gt;
&lt;p&gt;Structural conformance removes the choice. A content type lists every contract it satisfies, and the platform validates each independently. Nothing competes for a parent slot, so there is no diamond problem to resolve and no hierarchy depth to reason about &amp;mdash; the same component simply appears under several contracts at once.&lt;/p&gt;
&lt;h3&gt;Contracts that span sources &amp;mdash; the part worth pausing on&lt;/h3&gt;
&lt;p&gt;This is one of the most compelling capabilities of the structural model, yet one of the least discussed.&lt;/p&gt;
&lt;p&gt;Class inheritance cannot cross a source boundary. A base class defined within one application cannot be directly inherited by an external DAM API response or a commerce catalog item, because independent systems share no common runtime or compiler. Whatever elegance an inheritance hierarchy provides, it stops at the boundary of that specific application runtime.&lt;/p&gt;
&lt;p&gt;Structural conformance has no such limit. Conformance only asks about shape &amp;mdash; it is indifferent to where the data originated. Optimizely Graph registers global contracts, prefixed with an underscore, that serve as parent types across multiple sources rather than CMS content alone.&lt;/p&gt;
&lt;p&gt;The practical consequence is compelling: an externally sourced asset and a native CMS item can align to the same contract shape, making unified polymorphic querying possible across system boundaries. For teams working with an external DAM, a PIM, or a commerce catalog alongside CMS content, that opens up a class of queries that an inheritance model could rarely express without custom translation or synchronization pipelines.&lt;/p&gt;
&lt;h3&gt;Editorial context without API fragmentation&lt;/h3&gt;
&lt;p&gt;Two teams sharing a field rarely want to label it identically. One needs &lt;em&gt;Editorial Topic&lt;/em&gt; grouped with metadata; the other needs &lt;em&gt;Event Series&lt;/em&gt; grouped with scheduling. In a structural model, presentation and structure are separable: editorial labels adapt to each team&#39;s context while the underlying GraphQL interface remains a single, stable contract.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;What the platform locks and what it leaves to you&lt;/h2&gt;
&lt;p&gt;Because properties live directly on each content type, teams naturally ask: &lt;em&gt;what is genuinely bound by the contract, and what can be customized for content authors?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;When you inspect a contract-implemented property in the CMS editing interface or examine the CLI validation rules, the boundary splits cleanly into two distinct buckets:&lt;/p&gt;
&lt;h3&gt;Bucket 1: Field identity (Bound by contract)&lt;/h3&gt;
&lt;p&gt;These are the structural invariants that define what the property fundamentally is in Optimizely Graph. In the CMS editing interface, these settings appear disabled with a grey background. If you attempt to modify them on an implementing type via code or the CLI push command, the platform halts with a validation error:&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Property key / name:&lt;/strong&gt; The programmatic identifier (e.g., &lt;strong&gt;category&lt;/strong&gt;, &lt;strong&gt;heading&lt;/strong&gt;).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Underlying data type:&lt;/strong&gt; The scalar or complex type definition (e.g., &lt;strong&gt;Choice&lt;/strong&gt;, &lt;strong&gt;String&lt;/strong&gt;, &lt;strong&gt;ContentReference&lt;/strong&gt;).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Format:&lt;/strong&gt; The technical display and storage format (e.g., &lt;strong&gt;Drop-down list&lt;/strong&gt; for a choice property).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Property indexing type:&lt;/strong&gt; How Optimizely Graph indexes the field for search and filtering (e.g., &lt;strong&gt;Default&lt;/strong&gt;, &lt;strong&gt;Searchable&lt;/strong&gt;).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Mandatory entry (Value must be entered):&lt;/strong&gt; Whether the field is strictly required across all content instances.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The platform locks these down because Optimizely Graph generates a single, unified GraphQL interface for the contract. The API must guarantee that every implementing content type resolves the exact same scalar or object shape under that property name.&lt;/p&gt;
&lt;h3&gt;Bucket 2: Authoring experience and workflow configuration (Tailored in the CMS UI)&lt;/h3&gt;
&lt;p&gt;Everything outside that locked identity is an authoring-time configuration decision. These settings remain fully editable on the implementing content type, allowing teams to tune the editorial experience for each specific content model without altering the GraphQL schema:&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Dropdown choices and options:&lt;/strong&gt; A choice property defined on a contract establishes that a scalar string is returned. Individual content types can freely define, add, or refine the specific list of selectable choices (e.g., a blog article offering editorial topics while an event page offers conference tracks). In Optimizely Graph, the value is stored and queried as a valid scalar string regardless of which option the editor selects.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Localization (Unique value per language):&lt;/strong&gt; Whether a field must be translated independently per language branch or shared across all locales can be toggled per content type based on editorial requirements.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Allowed and restricted reference types:&lt;/strong&gt; Content reference constraints can be tuned to fit the exact nesting rules of that specific component or page.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Input limits:&lt;/strong&gt; Character bounds (&lt;strong&gt;maxLength&lt;/strong&gt;, &lt;strong&gt;minLength&lt;/strong&gt;) and array counts (&lt;strong&gt;minItems&lt;/strong&gt;, &lt;strong&gt;maxItems&lt;/strong&gt;) that guide editorial entry.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Default values:&lt;/strong&gt; Whether the field starts empty, inherits a default, or pre-populates a custom value.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Editorial presentation:&lt;/strong&gt; The display name, explanatory help text, property group tab, and display order seen by editors in the CMS editing view.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Why this division matters&lt;/h3&gt;
&lt;p&gt;Separating field identity from authoring configuration solves a classic enterprise dilemma: it provides API uniformity without forcing editorial rigidity.&lt;/p&gt;
&lt;p&gt;Optimizely Graph receives a consistent, queryable property across all types implementing the contract. At the same time, content authors receive domain-specific labels, tailored dropdown choices, and relevant help text that match their daily workflow. The contract secures the data architecture; the CMS UI empowers the authoring experience.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The trade-offs worth knowing before you go further&lt;/h2&gt;
&lt;p&gt;No pattern is free, and this one has trade-offs worth naming clearly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Definitions repeat.&lt;/strong&gt; Each implementing type declares its own properties. &quot;Composition&quot; here does not mean one definition consumed in many places &amp;mdash; it means many declarations validated against one shape. If that feels like duplication, that is a fair reading. It is the price of a model that maps one-to-one onto a GraphQL interface.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Contract changes fan out.&lt;/strong&gt; Add a property to a contract and every implementing type needs updating. The platform validates conformance; it does not perform the change on your behalf. For a contract with fifteen implementers, that is fifteen declarations to keep aligned.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Branch and release coordination becomes yours.&lt;/strong&gt; In teams with multiple developers working across separate feature branches, contracts represent a shared dependency. The CLI is vigilant here: when a contract change creates an incompatibility with existing content types, the push command halts with a breaking change warning and prompts whether you want to proceed with the &lt;strong&gt;--force&lt;/strong&gt; flag. But in an automated CI/CD pipeline &amp;mdash; and in any higher environment (QA, Staging, or Production) where &lt;strong&gt;--force&lt;/strong&gt; is rightly restricted to protect valuable content &amp;mdash; an uncoordinated push simply stops the deployment. If Developer A updates a contract for one type, but other implementers across active branches have not yet caught up, the pipeline halts until the entire contract family is reconciled.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Granularity is entirely your call.&lt;/strong&gt; One broad contract with twenty properties forces implementers to carry fields they do not need. Fifteen micro-contracts produce query fatigue and cognitive overhead. There is no platform guidance that decides this for you, and no automated signal that you have drawn the line badly. Modelling around functional capabilities &amp;mdash; routable, categorizable, SEO-trackable &amp;mdash; provides a proven heuristic to keep contracts cohesive and reusable.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Inheritance instincts assume automatic propagation.&lt;/strong&gt; Because contracts standardize shared fields, it is natural to assume that modifying a contract will automatically propagate changes down to all implementing types. Recognizing that contracts are validation interfaces rather than base classes is an essential mental model to establish across the team early.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Structure can be validated; intent cannot.&lt;/strong&gt; A property can match a contract&#39;s shape perfectly and still drift semantically &amp;mdash; same type, same key, quietly different meaning across two implementers. No schema check catches that. It is a modelling discipline question, and it stays a human one.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The governance the platform will not build for you&lt;/h2&gt;
&lt;p&gt;A clear conceptual framing helps teams navigate this transition:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The compiler did not disappear. It moved.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Previously, a server-side compiler and startup routine verified model integrity. In a decoupled SaaS architecture, that responsibility naturally shifts to your deployment pipeline and engineering conventions. That is not a limitation &amp;mdash; it is the natural consequence of moving to a declarative, multi-tenant platform. But it does mean the discipline must be deliberate rather than automatic.&lt;/p&gt;
&lt;p&gt;Three practices keep this model robust at scale.&lt;/p&gt;
&lt;h3&gt;Shared definitions in code&lt;/h3&gt;
&lt;p&gt;Rather than hand-maintaining the same property definition across implementers, define the shared shape once and compose it:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-typescript&quot;&gt;export const categorizable = {
  Category: { type: &#39;string&#39; },
  Tags: { type: &#39;array&#39;, items: { type: &#39;string&#39; } },
} as const;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-typescript&quot;&gt;import { categorizable } from &#39;../contracts/categorizable&#39;;

export const articlePage = {
  key: &#39;ArticlePage&#39;,
  baseType: &#39;_page&#39;,
  contracts: [&#39;Categorizable&#39;],
  properties: {
    ...categorizable,
    Excerpt: { type: &#39;string&#39; },
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The structural definition has one home. Per-type presentation settings layer on top of the spread. If you want to go further, a TypeScript helper that accepts only presentation keys as overrides will let the compiler block structural divergence before it reaches a push &amp;mdash; which is, pleasingly, the compiler coming back to help in a slightly different form.&lt;/p&gt;
&lt;h3&gt;A contract change protocol&lt;/h3&gt;
&lt;p&gt;Treat a contract change as a coordinated unit of work rather than an individual edit: identify every implementing type, update them together, deploy them together, then verify the generated schema reflects what you expected. Writing this down as a short runbook takes twenty minutes and prevents branches or environments from falling out of sync.&lt;/p&gt;
&lt;h3&gt;Contract ownership&lt;/h3&gt;
&lt;p&gt;Decide who can create a contract and what justifies one. A simple rule that has served well: do not create a contract until a genuine second implementer exists. Shared shapes should emerge from observed reuse rather than from anticipated reuse.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Where schema governance moves next&lt;/h2&gt;
&lt;p&gt;With the release of the Model Context Protocol (MCP) server for CMS SaaS, AI tooling can now access live CMS schema context directly. Rather than treating AI as a content generation tool alone, this capability opens up a natural extension for architectural governance.&lt;/p&gt;
&lt;p&gt;The decisions that remain challenging in a structural model are not structural invariants &amp;mdash; the platform validates those on push. They are architectural judgements: should a capability be one contract or two? Has a contract update been coordinated across all implementers? Has a field drifted in meaning while remaining structurally valid?&lt;/p&gt;
&lt;p&gt;These are pattern-matching evaluations against an established schema framework. An AI assistant with live schema context can serve as an effective decision accelerator: surfacing implementers across active branches, identifying uncoordinated contract updates, or evaluating whether a proposed property belongs on a shared contract or directly on a specific content type.&lt;/p&gt;
&lt;p&gt;The objective here is decision &lt;em&gt;acceleration&lt;/em&gt;, not decision &lt;em&gt;delegation&lt;/em&gt;. Content modeling choices fundamentally require human domain understanding. Yet providing architects and development teams with an automated, context-aware first pass on schema alignment is a compelling architectural evolution.&lt;/p&gt;
&lt;p&gt;The compiler was once a server-side runtime, then it became a CI/CD pipeline, and it is increasingly becoming an intelligent assistant.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Closing thought&lt;/h2&gt;
&lt;p&gt;The transition from base classes to contracts can initially feel like a loss &amp;mdash; moving away from something a server-side runtime previously handled automatically.&lt;/p&gt;
&lt;p&gt;Yet in a composable architecture &amp;mdash; where content flows across a CMS, an external DAM, a commerce catalog, and connected sources simultaneously &amp;mdash; structural conformance addresses a reality that a single application runtime was never designed to span.&lt;/p&gt;
&lt;p&gt;The enforcement did not vanish when the server-side runtime left; it relocated to your deployment pipeline, your code conventions, and your team&#39;s shared understanding of the model. Designing for that relocation from day one is what turns a potential governance challenge into a durable, scalable architecture.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;This article is grounded in the Optimizely Graph schema specifications, official CMS SaaS documentation, and real-world CLI deployment workflows.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Documentation references: &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs/create-contracts&quot;&gt;Create contracts&lt;/a&gt;, &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs/content-types&quot;&gt;Define content types&lt;/a&gt;, &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/reference/contenttypes_create&quot;&gt;Create content type API reference&lt;/a&gt;, &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs/content-base-types-saas&quot;&gt;Content base types&lt;/a&gt;, &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs/mcp-server-overview&quot;&gt;CMS (SaaS) MCP server overview&lt;/a&gt;.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/vipin-banka--learnings--insights/dates/2026/9/composition-over-inheritance-why-your-content-contract-is-not-a-base-class/</guid>            <pubDate>Sat, 19 Sep 2026 08:44:58 GMT</pubDate>           <category>Blog post</category></item><item> <title>Optimizely Opti ID Integration: Why the API Key Was Missing from the DXP Management Portal</title>            <link>https://madhuanbalagan.com/?p=5075</link>            <description>&lt;p&gt;While working on an Optimizely CMS 12 Opti ID integration recently, we ran into an issue that initially appeared to be related to SSO authentication&amp;#46;&amp;#46;&amp;#46;&lt;/p&gt;
&lt;p&gt;The post &lt;a href=&quot;https://madhuanbalagan.com/optimizely-opti-id-integration-why-the-api-key-was-missing-from-the-dxp-management-portal&quot;&gt;Optimizely Opti ID Integration: Why the API Key Was Missing from the DXP Management Portal&lt;/a&gt; appeared first on &lt;a href=&quot;https://madhuanbalagan.com&quot;&gt;Madhu Anbalagan&amp;#039;s Blog&lt;/a&gt;.&lt;/p&gt;
</description>            <guid>https://madhuanbalagan.com/?p=5075</guid>            <pubDate>Fri, 18 Sep 2026 16:32:00 GMT</pubDate>           <category>Blog post</category></item><item> <title>Extending a Contract: What You Can Override</title>            <link>http://optimizelyfoundation.com/?p=672</link>            <description>Contracts share fields across content types — but not every field setting can be overridden. Push the same contract onto several components, and CMS will accept some changes and reject others. This post lists what can change, and what will fail the push.</description>            <guid>http://optimizelyfoundation.com/?p=672</guid>            <pubDate>Fri, 18 Sep 2026 09:41:29 GMT</pubDate>           <category>Blog post</category></item><item> <title>Building a Blazor Server UI Inside the Optimizely CMS 13 Admin Shell</title>            <link>https://szolkowski.github.io/2026/09/17/building-a-blazor-server-ui-inside-the-optimizely-cms-13-admin-shell</link>            <description>What I learned shipping a Blazor Server UI inside Optimizely CMS 13 — Razor components in a NuGet package, the Blazor hub next to MapContent(), prerendering.</description>            <guid>https://szolkowski.github.io/2026/09/17/building-a-blazor-server-ui-inside-the-optimizely-cms-13-admin-shell</guid>            <pubDate>Thu, 17 Sep 2026 08:00:00 GMT</pubDate>           <category>Blog post</category></item><item> <title>Giving Optimizely 13 Content Types Meaningful Icons and Thumbnails</title>            <link>http://parwissmark.wordpress.com/?p=467</link>            <description>When working with an Optimizely CMS solution that has evolved over time, it is common to end up with many content types. As the number grows, the default editor UI can become harder to scan. Editors may need to spend more time working out what a content type represents before they can create, find or [&amp;#8230;]</description>            <guid>http://parwissmark.wordpress.com/?p=467</guid>            <pubDate>Wed, 16 Sep 2026 13:55:46 GMT</pubDate>           <category>Blog post</category></item><item> <title>force-dynamic&#160;vs&#160;connection()&#160;for Optimizely Graph</title>            <link>http://optimizelyfoundation.com/?p=653</link>            <description>If you have built an Optimizely CMS SDK site, you already know getContentByPath on a catch-all. This post is about the part that file never says out loud: when that Graph call runs. We will unpack force-dynamic versus await connection(), and how each one changes prerendering for Optimizely Graph.</description>            <guid>http://optimizelyfoundation.com/?p=653</guid>            <pubDate>Wed, 16 Sep 2026 13:01:39 GMT</pubDate>           <category>Blog post</category></item><item> <title>I built a thing - nOSC &amp; nOCP - an experiment?</title>            <link>https://world.optimizely.com/blogs/matt-pallatt-is-not-a-developer/dates/2026/9/nosc--nocp---an-experiment/</link>            <description>&lt;p&gt;I don&#39;t think I&#39;m&lt;span style=&quot;text-decoration: underline; color: rgb(224, 62, 45);&quot;&gt; &lt;a style=&quot;color: rgb(224, 62, 45); text-decoration: underline;&quot; href=&quot;/link/134938996abe4a4e86778461a493b978.aspx&quot;&gt;always that convensional&lt;/a&gt;&lt;/span&gt;. And that&#39;s where this starts...&lt;/p&gt;
&lt;p&gt;So firstly, I was lucky enough to see OCP UI extensions for Optimizey SaaS a few months back - these are apps that extend the capabilities of the SaaS CMS platform using typescript apps built and hosted on the Optimizely Connect Platform (OCP). And I was even more lucky to get to be involved in the Beta program, getting first hand experience of what the platform can do. And well, let&#39;s just say that firstly I was impressed and then secondly I got carried away.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;text-decoration: underline; color: rgb(224, 62, 45);&quot;&gt;&lt;a style=&quot;color: rgb(224, 62, 45); text-decoration: underline;&quot; href=&quot;/link/b2da5c49916b4aeeb35b293a1cbf1312.aspx&quot;&gt;Content Transfer&lt;/a&gt;&lt;/span&gt; got migrated. A client we&#39;re working with uses Frontify as a DAM, so a picker got spun out of the OCP UI boiler plate - complete with it&#39;s own new search functionality and image manipulation features. This was partnered with a Focal Point Editor that allows CMS users to tell their websites&#39; Head where the focus of the image should be. Then I used the Webhook mechanisms within OCP to create a whole CSP management suite with reporting. And then I built a full redirection management system, complete with solves for moved pages in the CMS and handling 404s.&lt;/p&gt;
&lt;p&gt;As I say, I got carried away.&lt;/p&gt;
&lt;p&gt;Then two things made me stop pretty much dead in my tracks - first, it turns out that actually, OCP isn&#39;t the most performant platform on the planet, and &quot;Please Try Again&quot; when trying to open apps is very infuriating, and then secondly, having considered OCP to be free when not using the database, it turned out that this wasn&#39;t the case, and subsequently that the pricing model for hosting OCP UI apps hadn&#39;t (hasn&#39;t?) been decided - so I&#39;d been solving my real world problems in OCP, and it could well turn out that the cost of hosting them would make running a bunch of them significantly less than attractive - and they might keep returning &quot;Please try again&quot; on content queries.&lt;/p&gt;
&lt;p&gt;Which is when my sneaky, non-conventional brain kicked in - I thought what if we could...&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Host apps freely?&lt;/li&gt;
&lt;li&gt;Be assured of their performance?&lt;/li&gt;
&lt;li&gt;Secure apps on our own terms?&lt;/li&gt;
&lt;li&gt;Upload them in seconds, not minutes?&lt;/li&gt;
&lt;li&gt;Have apps share information between panes and be able to actually effect change in the CMS, and not be restricted to Graph and the API for Optimizely content?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So, I built it.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&quot;&lt;span style=&quot;text-decoration: underline; color: rgb(224, 62, 45);&quot;&gt;&lt;a style=&quot;color: rgb(224, 62, 45); text-decoration: underline;&quot; href=&quot;https://chromewebstore.google.com/detail/not-optis-saas-companion/ohfndpjebedoeicpjjifjeheedcjdbhl?authuser=0&amp;amp;hl=en-GB&quot;&gt;not Optimizely&#39;s SaaS Companion&lt;/a&gt;&lt;/span&gt;&quot; - nOSC. It rolls off the tongue.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;It&#39;s a Chrome extension that (amongst other things&lt;strong&gt;*&lt;/strong&gt;) acts as a harness for nOCP apps - what are nOCP apps? Definitely not OCP apps. But yes, they&#39;re typescript apps (or not), that can be hosted anywhere you want (my favourite free hosting choice is Lambda on AWS); there is a security contract that must be observed, including a token to ensure you can&#39;t just sideload other peoples nOCP apps into your nOSC harness, but ultimately you can build an app in whatever language or framework you like, so long as it conforms to &quot;the contract&quot; and token exchange rules.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;nOSC works by injecting into your SaaS CMS (sidebar and fullscreen), but unlike the Optimizely OCP apps, because of the Chrome app proxy, there is two way communication between CMS and app - AND - access to the numeric page ID of whatever content you&#39;re looking at in the CMS, which means that in building nOCP apps you can actually control the top level page and use the page ID to link directly to editable content.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;Here are 4 docked oCP apps, and 1 docked nOCP app - can you tell the difference?&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/db466d40096f4f53ae38bd94e0ceab94.aspx&quot; width=&quot;419&quot; height=&quot;234&quot; /&gt;&lt;/p&gt;
&lt;p&gt;But more than that - you can also push content into the CMS! As well as porting over a few of the OCP UI apps that I&#39;ve built to be nOCP apps, I&#39;ve also built a Category Explorer around the new SaaS Taxonomy implementation - an implementation that sees you guessing what categories might be called with some kind of predictive search interface. I did not like that so nOSC and nOCP to the rescue - Graph allows me to list categories, my Chrome app allows me to push selected categories into the Category field of the CMS content item.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/e2a1395da5004e38b0865eaaefb0802f.aspx?1789415668536&quot; width=&quot;852&quot; height=&quot;400&quot; /&gt;&lt;/p&gt;
&lt;p&gt;You can see a selection of my nOCP apps at&amp;nbsp;&lt;span style=&quot;text-decoration: underline; color: rgb(224, 62, 45);&quot;&gt;&lt;a style=&quot;color: rgb(224, 62, 45); text-decoration: underline;&quot; href=&quot;https://github.com/mattpallatt/nOCP-apps&quot;&gt;https://github.com/mattpallatt/nOCP-apps&lt;/a&gt;&lt;/span&gt; - along with more information on that security contract I mentioned.&lt;/p&gt;
&lt;p&gt;They&#39;re configured both on server for connections into secure endpoints (once), and then in browser to pull the app into the harness (everyone who wants the app).&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/064ed83b6b4f4c7fb33e42efd01e5fab.aspx&quot; width=&quot;406&quot; height=&quot;431&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/8943ac706a114e91846d43ec459808ae.aspx&quot; width=&quot;406&quot; height=&quot;327&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Excecpt of course, it would be really annoying trying to make sure that your entire SaaS CMS team have the same set of tools, right? So there&#39;s a import/export feature, and more importantly a &quot;Load from Global configuration&quot; in nOSC - set your enviroments, apps and other configs up once, export them as an encrypted configuration file and save it somewhere that the internet can see, then your entire teams browsers can automatically download and configure the app when their Chrome/Edge browser starts - apps automatically synced with everyone having access to the same tools with no fuss.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/3ccf4b5245e9420ab1637209db23da88.aspx&quot; width=&quot;640&quot; height=&quot;284&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Right, so back to that asterisk up there. It&#39;s not _just_ about loading apps - one of my pet peeves with SaaS is not being able to allow CMS users to use custom styles in TinyMCE, so nOSC aims to handle that too.&lt;/p&gt;
&lt;p&gt;On an enviroment by environment, text property type configuration (minimal, standard, enhanced) basis, you can add new styling options that are either applied as inline styles, or by dropping class names onto selected text. It&#39;s not quite &quot;in the dropdown&quot;, but it&#39;s a meaningful step in the right direction IMO.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/53bb5a29c29941fd9a871868da02ad17.aspx&quot; width=&quot;501&quot; height=&quot;124&quot; /&gt;&lt;/p&gt;
&lt;p&gt;That&#39;s nOSC.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;I would very much like to know what people think of this - I&#39;ve said it&#39;s an experiment, and maybe OCP apps come out in October and they are free to host, and they bump up the performance, and releasing to hosting takes less than 5 minutes, and you don&#39;t have to worry about timeouts - but, if all that doesn&#39;t happen, then does this &quot;experiment&quot; become something that you and clients could use? &quot;No, you crazy person&quot; is a perfectly reasonable response.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/matt-pallatt-is-not-a-developer/dates/2026/9/nosc--nocp---an-experiment/</guid>            <pubDate>Tue, 15 Sep 2026 14:00:55 GMT</pubDate>           <category>Blog post</category></item><item> <title>A day in the life of an Optimizely OMVP: OptiA11y: accessibility help for Optimizely editors that refuses to lie to them</title>            <link>https://world.optimizely.com/blogs/allthingsopti/dates/2026/9/a-day-in-the-life-of-an-optimizely-omvp-optia11y-accessibility-help-for-optimizely-editors-that-refuses-to-lie-to-them/</link>            <description>&lt;p class=&quot;code-line&quot;&gt;Most accessibility tooling in the CMS space has the same shape. A crawler walks your published site, renders each page, runs axe-core over the DOM, and hands back a dashboard with a number on it. That number goes into a slide deck. Everybody feels better.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;There are two problems with this.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;The first is that a crawler sees a&amp;nbsp;&lt;em&gt;page&lt;/em&gt;, and an editor edits a&amp;nbsp;&lt;em&gt;property&lt;/em&gt;. When the report says &quot;image missing alt text on /products/kayaks&quot;, the editor still has to work out which of the four content blocks on that page owns the image, open the right one, and find the right field. The tool found the problem in the artefact; the human has to find it in the source.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;The second problem is worse. Automated checks can only detect a fraction of WCAG failures, and the checks that matter most to&amp;nbsp;&lt;em&gt;editors, &lt;/em&gt;is this alt text actually describing the image, does this link text make sense out of context, is this paragraph readable, are exactly the ones a machine cannot be certain about. Tools resolve that uncertainty in one of two ways: they stay silent (useless), or they assert a pass/fail verdict they have no business asserting (dangerous). A green score on a page full of &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;alt=&quot;IMG_04213.jpg&quot;&lt;/span&gt;&amp;nbsp;is not neutral. It is actively misleading, and somebody will eventually put it in front of a regulator.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;&lt;a href=&quot;https://github.com/adayinthelifeofapro/OptiA11y&quot;&gt;OptiA11y&lt;/a&gt;&amp;nbsp;is my attempt at the third option: an Optimizely add-on that analyses content at the property level, tells editors precisely where a problem lives, and is architecturally incapable of claiming certainty it doesn&#39;t have.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;&lt;img src=&quot;/link/2fcc4be92c304c1592204302f371ec31.aspx&quot; alt=&quot;&quot; width=&quot;1000&quot; height=&quot;667&quot; /&gt;&lt;/p&gt;
&lt;h2 class=&quot;code-line&quot;&gt;The Confidence invariant&lt;/h2&gt;
&lt;p class=&quot;code-line&quot;&gt;Everything else in the codebase is downstream of one design decision, so let&#39;s start there.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;Every finding OptiA11y produces carries a&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Confidence&lt;/span&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;code-line language-csharp&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;hljs-built_in&quot;&gt;enum&lt;/span&gt; Confidence
{
    Pass,

    &lt;span class=&quot;hljs-comment&quot;&gt;&lt;span class=&quot;hljs-doctag&quot;&gt;///&lt;/span&gt; &lt;span class=&quot;hljs-doctag&quot;&gt;&amp;lt;summary&amp;gt;&lt;/span&gt;&lt;/span&gt;
    &lt;span class=&quot;hljs-comment&quot;&gt;&lt;span class=&quot;hljs-doctag&quot;&gt;///&lt;/span&gt; A heuristic judgement flagged something worth an editor&#39;s attention, but it is not&lt;/span&gt;
    &lt;span class=&quot;hljs-comment&quot;&gt;&lt;span class=&quot;hljs-doctag&quot;&gt;///&lt;/span&gt; a certain violation. This is the default for anything involving natural-language quality.&lt;/span&gt;
    &lt;span class=&quot;hljs-comment&quot;&gt;&lt;span class=&quot;hljs-doctag&quot;&gt;///&lt;/span&gt; &lt;span class=&quot;hljs-doctag&quot;&gt;&amp;lt;/summary&amp;gt;&lt;/span&gt;&lt;/span&gt;
    NeedsReview,

    &lt;span class=&quot;hljs-comment&quot;&gt;&lt;span class=&quot;hljs-doctag&quot;&gt;///&lt;/span&gt; &lt;span class=&quot;hljs-doctag&quot;&gt;&amp;lt;summary&amp;gt;&lt;/span&gt;A deterministic, unambiguous violation of the success criterion.&lt;span class=&quot;hljs-doctag&quot;&gt;&amp;lt;/summary&amp;gt;&lt;/span&gt;&lt;/span&gt;
    Fail
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;code-line&quot;&gt;The rule is simple and absolute:&amp;nbsp;&lt;strong&gt;a rule whose judgement depends on natural-language quality or editorial intent must never emit&amp;nbsp;&lt;/strong&gt;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Fail&lt;/span&gt;&lt;strong&gt;.&lt;/strong&gt;&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Fail&amp;nbsp;&lt;/span&gt;is reserved for deterministic structural facts, a missing&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;alt&lt;/span&gt;&amp;nbsp;attribute, a skipped heading level, a contrast ratio mathematically below 4.5:1, an&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;&amp;lt;iframe&amp;gt;&lt;/span&gt;&amp;nbsp;with no&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;title&lt;/span&gt;. Everything else, whether alt text is&amp;nbsp;&lt;em&gt;good&lt;/em&gt;, whether link text is&amp;nbsp;&lt;em&gt;descriptive&lt;/em&gt;, whether that manually bulleted paragraph is&amp;nbsp;&lt;em&gt;really&lt;/em&gt; a list, is&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;NeedsReview&lt;/span&gt;.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Severity&amp;nbsp;&lt;/span&gt;is a completely separate axis. Severity is impact; confidence is certainty. A&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;NeedsReview&amp;nbsp;&lt;/span&gt;finding can be&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Critical&lt;/span&gt;, and a&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Fail&amp;nbsp;&lt;/span&gt;can be&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Info&lt;/span&gt;. Conflating the two is how you end up with a score that means nothing.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;You can see both axes in one rule. Here is the alt-text rule, interesting precisely because it straddles the line:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;code-line language-csharp&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;private&lt;/span&gt; Finding? EvaluateImage(ImageFragment image)
{
    &lt;span class=&quot;hljs-comment&quot;&gt;// Attribute entirely absent is a deterministic, unambiguous violation.&lt;/span&gt;
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (image.AltText &lt;span class=&quot;hljs-keyword&quot;&gt;is&lt;/span&gt; &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt;)
    {
        &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; Fail(image, &lt;span class=&quot;hljs-string&quot;&gt;&quot;This image has no alt attribute at all. Add one, or mark it decorative if it conveys no information.&quot;&lt;/span&gt;);
    }

    &lt;span class=&quot;hljs-comment&quot;&gt;// An empty alt is a valid, deliberate way to mark an image as decorative &amp;mdash; but only&lt;/span&gt;
    &lt;span class=&quot;hljs-comment&quot;&gt;// when the surrounding context supports that. We cannot be certain, so this is a review.&lt;/span&gt;
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (image.AltText.Length == &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;)
    {
        &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; image.IsDecorativeCandidate
            ? &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt;
            : NeedsReview(image, &lt;span class=&quot;hljs-string&quot;&gt;&quot;This image has empty alt text but doesn&#39;t look decorative. Confirm it truly conveys no information, or add a description.&quot;&lt;/span&gt;);
    }

    &lt;span class=&quot;hljs-keyword&quot;&gt;var&lt;/span&gt; altText = image.AltText.Trim();

    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (FilenamePattern.IsMatch(altText))
    {
        &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; NeedsReview(image, &lt;span class=&quot;hljs-string&quot;&gt;$&quot;The alt text \&quot;&lt;span class=&quot;hljs-subst&quot;&gt;{image.AltText}&lt;/span&gt;\&quot; looks like a filename rather than a description. Consider describing what the image shows.&quot;&lt;/span&gt;);
    }

    &lt;span class=&quot;hljs-comment&quot;&gt;// ... redundant &quot;image of&quot; prefixes, excessive length &amp;mdash; all NeedsReview&lt;/span&gt;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;code-line&quot;&gt;A missing&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;alt&amp;nbsp;&lt;/span&gt;is a fact.&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;alt=&quot;IMG_04213.jpg&quot;&lt;/span&gt; is a very strong hint, and still only a hint, there is a vanishingly small world in which that filename is the correct description. So the tool says so, in those words, and lets a human decide.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;A design principle only counts if something enforces it. Here that something is a test file called&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;HeuristicRulesNeverFailTests&lt;/span&gt;, which feeds every heuristic rule inputs designed to trip its judgement branches and asserts nothing comes back as a&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Fail&lt;/span&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;code-line language-csharp&quot;&gt;[&lt;span class=&quot;hljs-meta&quot;&gt;Fact&lt;/span&gt;]
&lt;span class=&quot;hljs-function&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;hljs-title&quot;&gt;ReadingLevelRule_NeverReportsFail&lt;/span&gt;()&lt;/span&gt;
{
    &lt;span class=&quot;hljs-keyword&quot;&gt;var&lt;/span&gt; rule = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; ReadingLevelRule();
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt; difficultPassage =
        &lt;span class=&quot;hljs-string&quot;&gt;&quot;Notwithstanding the aforementioned considerations, the organizational infrastructure &quot;&lt;/span&gt; +
        &lt;span class=&quot;hljs-string&quot;&gt;&quot;necessitates a comprehensive reevaluation of preexisting methodological frameworks ...&quot;&lt;/span&gt;;

    &lt;span class=&quot;hljs-keyword&quot;&gt;var&lt;/span&gt; findings = rule.Evaluate(&lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; AuditDocument(&lt;span class=&quot;hljs-string&quot;&gt;&quot;content-1&quot;&lt;/span&gt;,
        &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; ContentFragment[] { &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; TextFragment(TestLocations.OnMainBody(&lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;), difficultPassage, &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt;) })).ToList();

    Assert.NotEmpty(findings);
    Assert.DoesNotContain(findings, f =&amp;gt; f.Confidence == Confidence.Fail);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;code-line&quot;&gt;The&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Assert.NotEmpty&lt;/span&gt;&amp;nbsp;matters as much as the&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;DoesNotContain&lt;/span&gt;. It stops a future refactor from satisfying the invariant by accidentally making the rule silent.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;The file also documents the deliberate exceptions, which is the part I&#39;d encourage you to steal if you take nothing else from this post.&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;HeadingStructureRule&amp;nbsp;&lt;/span&gt;&lt;em&gt;may&lt;/em&gt; fail, a skip from H1 to H3 is a structural fact, but its missing-H1 check is a judgement call, because in a CMS the H1 very often comes from the page template rather than the property being audited. So that rule gets two tests: one asserting the structural branch &lt;em&gt;does&lt;/em&gt;&amp;nbsp;fail, and one isolating the heuristic branch and asserting it doesn&#39;t. The exceptions are as tested as the rule.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;ColorContrastRule&lt;/span&gt; has the same shape. Contrast is arithmetic, so it fails, unless the background came from rendered CSS and turned out to be an image or a gradient:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;code-line language-csharp&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (fragment.BackgroundIsImage)
{
    &lt;span class=&quot;hljs-function&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;yield&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title&quot;&gt;Finding&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;..., Confidence.NeedsReview, fragment.Location,
        &lt;span class=&quot;hljs-string&quot;&gt;$&quot;Text \&quot;&lt;span class=&quot;hljs-subst&quot;&gt;{fragment.SampleText}&lt;/span&gt;\&quot; sits over an image or gradient background rather than a flat color. &quot;&lt;/span&gt; +
        &lt;span class=&quot;hljs-string&quot;&gt;$&quot;Its computed contrast ratio (&lt;span class=&quot;hljs-subst&quot;&gt;{fragment.ContrastRatio:F2}&lt;/span&gt;:1) isn&#39;t reliable - check contrast manually &quot;&lt;/span&gt; +
        &lt;span class=&quot;hljs-string&quot;&gt;&quot;against the busiest part of the image behind it.&quot;&lt;/span&gt;&lt;/span&gt;)&lt;/span&gt;;
    &lt;span class=&quot;hljs-keyword&quot;&gt;continue&lt;/span&gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;code-line&quot;&gt;The sampled colour there is whatever solid colour happens to sit behind the image, not what a user actually sees. The arithmetic is still perfectly valid arithmetic over the wrong input, and a rule that can&#39;t tell the difference will confidently fail text that is fine, or pass text that isn&#39;t. Downgrading to&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;NeedsReview&amp;nbsp;&lt;/span&gt;is the honest answer.&lt;/p&gt;
&lt;h2 class=&quot;code-line&quot;&gt;Ports and adapters, because the CMS is the least interesting part&lt;/h2&gt;
&lt;p class=&quot;code-line&quot;&gt;The data flow for every audit is one line:&lt;/p&gt;
&lt;pre&gt;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;host content source &amp;rarr; adapter &amp;rarr; AuditDocument (ContentFragment[]) &amp;rarr; RuleEngine &amp;rarr; Finding[]&lt;/span&gt;
&lt;/pre&gt;
&lt;p class=&quot;code-line&quot;&gt;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;OptiA11y.Core&lt;/span&gt;&amp;nbsp;holds the engine, the model, and all 36 rules. Its only dependency is HtmlAgilityPack. It has&amp;nbsp;&lt;strong&gt;no&lt;/strong&gt; Optimizely/EPiServer reference, and that isn&#39;t tidiness for its own sake, it&#39;s what makes the entire rule set unit-testable with no CMS present, no host, no database, and no initialization pipeline. The core test project references Core alone and runs in seconds. That&#39;s the difference between a rule set people extend and one they&#39;re afraid to touch.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;Rules never see HTML, a DOM, or a CMS API. They see typed fragments:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;code-line language-csharp&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;sealed&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;hljs-title&quot;&gt;AuditDocument&lt;/span&gt;
{
    &lt;span class=&quot;hljs-keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt; ContentReference { &lt;span class=&quot;hljs-keyword&quot;&gt;get&lt;/span&gt;; }
    &lt;span class=&quot;hljs-function&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;hljs-title&quot;&gt;IReadOnlyList&lt;/span&gt;&amp;lt;&lt;span class=&quot;hljs-title&quot;&gt;ContentFragment&lt;/span&gt;&amp;gt; Fragments&lt;/span&gt; { &lt;span class=&quot;hljs-keyword&quot;&gt;get&lt;/span&gt;; }

    &lt;span class=&quot;hljs-function&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;hljs-title&quot;&gt;IEnumerable&lt;/span&gt;&amp;lt;&lt;span class=&quot;hljs-title&quot;&gt;T&lt;/span&gt;&amp;gt; &lt;span class=&quot;hljs-title&quot;&gt;Get&lt;/span&gt;&amp;lt;&lt;span class=&quot;hljs-title&quot;&gt;T&lt;/span&gt;&amp;gt;() &lt;span class=&quot;hljs-keyword&quot;&gt;where&lt;/span&gt; T : ContentFragment&lt;/span&gt; =&amp;gt; Fragments.OfType&amp;lt;T&amp;gt;();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;code-line&quot;&gt;&amp;hellip;and a rule is therefore about as small as a rule can be:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;code-line language-csharp&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;interface&lt;/span&gt; &lt;span class=&quot;hljs-title&quot;&gt;IContentRule&lt;/span&gt;
{
    &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt; RuleId { &lt;span class=&quot;hljs-keyword&quot;&gt;get&lt;/span&gt;; }            &lt;span class=&quot;hljs-comment&quot;&gt;// &quot;alt-text-quality&quot;&lt;/span&gt;
    &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt; SuccessCriterion { &lt;span class=&quot;hljs-keyword&quot;&gt;get&lt;/span&gt;; }  &lt;span class=&quot;hljs-comment&quot;&gt;// &quot;1.1.1&quot;&lt;/span&gt;
    WcagLevel Level { &lt;span class=&quot;hljs-keyword&quot;&gt;get&lt;/span&gt;; }

    &lt;span class=&quot;hljs-function&quot;&gt;IEnumerable&amp;lt;Finding&amp;gt; &lt;span class=&quot;hljs-title&quot;&gt;Evaluate&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;AuditDocument document&lt;/span&gt;)&lt;/span&gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;code-line&quot;&gt;One contract is worth calling out from the XML doc on&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Evaluate&lt;/span&gt;: rules must not throw for content that is merely unusual. Editors produce genuinely strange markup, pasted from Word, half-migrated, hand-edited in a source view at 5pm on a Friday. An unexpected shape means &quot;nothing to flag&quot;, never an exception. An audit that dies on one weird table is worth less than one that quietly skips it.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;Every fragment carries a&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;SourceLocation&lt;/span&gt;, and this is what solves the &quot;which block was it in?&quot; problem from the top of this post:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;code-line language-csharp&quot;&gt;&lt;span class=&quot;hljs-function&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;sealed&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;record&lt;/span&gt; &lt;span class=&quot;hljs-title&quot;&gt;SourceLocation&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;
    &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt; ContentReference,
    &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt; PropertyName,
    IReadOnlyList&amp;lt;&lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt;&amp;gt; BlockPath,   // [&lt;span class=&quot;hljs-string&quot;&gt;&quot;MainContentArea/Block:abc123&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&quot;NestedArea/Block:def456&quot;&lt;/span&gt;]
    &lt;span class=&quot;hljs-built_in&quot;&gt;int&lt;/span&gt; Ordinal&lt;/span&gt;)&lt;/span&gt;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;code-line&quot;&gt;Content reference, property name, the full path of nested blocks leading to the fragment, and an ordinal to disambiguate the third image in a rich-text property from the first. That&#39;s enough to reconstruct an editor URL, but Core deliberately doesn&#39;t reconstruct one. URL shapes differ between PaaS edit views and SaaS deep links, so Core exposes &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;IEditorLinkResolver&amp;nbsp;&lt;/span&gt;as a port and leaves it unimplemented. The payload lives in the model; the resolution lives in the host.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;RuleEngine&amp;nbsp;&lt;/span&gt;is then almost trivially boring, which is the point:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;code-line language-csharp&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; findings
    .OrderBy(f =&amp;gt; f.Location.ContentReference, StringComparer.Ordinal)
    .ThenBy(f =&amp;gt; f.Location.PropertyName, StringComparer.Ordinal)
    .ThenBy(f =&amp;gt; f.Location.ToPathString(), StringComparer.Ordinal)
    .ThenBy(f =&amp;gt; f.Location.Ordinal)
    .ThenBy(f =&amp;gt; f.RuleId, StringComparer.Ordinal)
    .ToList();
&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;code-line&quot;&gt;Sorting by location rather than rule order means results are stable across runs regardless of how DI happened to enumerate the rule set, which matters the moment you start diffing audit output between two versions of a page.&lt;/p&gt;
&lt;h3 class=&quot;code-line&quot;&gt;One parser, or the guarantee evaporates&lt;/h3&gt;
&lt;p class=&quot;code-line&quot;&gt;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;HtmlFragmentParser&amp;nbsp;&lt;/span&gt;(~900 lines, in&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Core/Parsing&lt;/span&gt;) is the&amp;nbsp;&lt;strong&gt;single&lt;/strong&gt; HTML&amp;rarr; fragment entry point. PaaS content, SaaS content and rendered-style enrichment all funnel through it. That&#39;s what makes rule behaviour identical regardless of where content came from, and there&#39;s a test standing over it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;code-line language-csharp&quot;&gt;[&lt;span class=&quot;hljs-meta&quot;&gt;Fact&lt;/span&gt;]
&lt;span class=&quot;hljs-function&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; Task &lt;span class=&quot;hljs-title&quot;&gt;PaasAndSaasAdapters_ProduceEquivalentFragmentCounts_ForEquivalentInput&lt;/span&gt;()&lt;/span&gt;
{
    &lt;span class=&quot;hljs-comment&quot;&gt;// same HTML, one via PaasContentAdapter, one via SaasContentAdapter&lt;/span&gt;
    Assert.Equal(paasDocument!.Get&amp;lt;HeadingFragment&amp;gt;().Count(), saasDocument!.Get&amp;lt;HeadingFragment&amp;gt;().Count());
    Assert.Equal(paasDocument.Get&amp;lt;ImageFragment&amp;gt;().Count(), saasDocument.Get&amp;lt;ImageFragment&amp;gt;().Count());
    Assert.Equal(paasDocument.Get&amp;lt;LinkFragment&amp;gt;().Count(), saasDocument.Get&amp;lt;LinkFragment&amp;gt;().Count());

    &lt;span class=&quot;hljs-keyword&quot;&gt;var&lt;/span&gt; paasHeading = paasDocument.Get&amp;lt;HeadingFragment&amp;gt;().Single();
    &lt;span class=&quot;hljs-keyword&quot;&gt;var&lt;/span&gt; saasHeading = saasDocument.Get&amp;lt;HeadingFragment&amp;gt;().Single();
    Assert.Equal(paasHeading.Text, saasHeading.Text);
    Assert.Equal(paasHeading.Location.PropertyName, saasHeading.Location.PropertyName);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;code-line&quot;&gt;Add parsing anywhere else and that guarantee is gone &amp;mdash; silently, and you won&#39;t notice until a customer on SaaS reports findings a customer on PaaS doesn&#39;t get.&lt;/p&gt;
&lt;h2 class=&quot;code-line&quot;&gt;The rule catalogue&lt;/h2&gt;
&lt;p class=&quot;code-line&quot;&gt;36 rules today, spanning WCAG A, AA and a few AAA criteria. Grouped roughly:&lt;/p&gt;
&lt;ul class=&quot;code-line&quot;&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;Text alternatives&lt;/strong&gt; - alt text quality; accessible names for inline SVG, image-map &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;&amp;lt;area&amp;gt;&lt;/span&gt;,&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;&amp;lt;object&amp;gt;&lt;/span&gt;/&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;&amp;lt;embed&amp;gt;&lt;/span&gt;.&lt;/li&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;Structure&lt;/strong&gt; - heading levels, faux headings (a short bold paragraph doing a heading&#39;s job), fake lists (manually bulleted text), table headers, table complexity, deprecated presentational tags.&lt;/li&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;Links&lt;/strong&gt; - accessible name, link purpose, document links that don&#39;t say they&#39;re a PDF.&lt;/li&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;Forms&lt;/strong&gt; - labels, fieldset/legend, input purpose (&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;autocomplete&lt;/span&gt;), button names.&lt;/li&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;ARIA and interaction&lt;/strong&gt; - invalid roles and &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;aria-*&lt;/span&gt;&amp;nbsp;names, broken&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;aria-labelledby&lt;/span&gt;&amp;nbsp;references, nested interactive controls,&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;aria-hidden&lt;/span&gt;&amp;nbsp;on focusable elements, positive&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;tabindex&lt;/span&gt;, duplicate&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;accesskey&lt;/span&gt;,&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;title&amp;nbsp;&lt;/span&gt;misuse.&lt;/li&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;Media&lt;/strong&gt; - captions, transcripts, audio description, unmuted autoplay.&lt;/li&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;Language&lt;/strong&gt; -&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;lang&lt;/span&gt;&amp;nbsp;declarations, and inline passages in another writing system with no override.&lt;/li&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;Readability and presentation&lt;/strong&gt; - colour contrast, justified text, sub-12px fonts, ALL CAPS runs, Flesch reading ease, sensory-characteristic instructions (&quot;click the green button on the right&quot;), page titling.&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;code-line&quot;&gt;Each is registered individually in DI (&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;ServiceCollectionExtensions.AddOptiA11y&lt;/span&gt;), so a host can drop the ones it doesn&#39;t want.&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;RuleEngine&amp;nbsp;&lt;/span&gt;takes&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;IEnumerable&amp;lt;IContentRule&amp;gt;&lt;/span&gt;, so adding your own is a class and one registration.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;Adding a rule to the project itself is five steps: emit (or reuse) a fragment from the parser, write the rule, register it, add a row to the README catalogue table, it&#39;s the user-facing contract, and, if it&#39;s heuristic, extend &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;HeuristicRulesNeverFailTests&lt;/span&gt;.&lt;/p&gt;
&lt;h2 class=&quot;code-line&quot;&gt;Installing as a CMS 12/13 add-on: zero host code&lt;/h2&gt;
&lt;p class=&quot;code-line&quot;&gt;The whole point of shipping this as an add-on is that a developer shouldn&#39;t have to wire anything up. Install the package and it self-registers through EPiServer&#39;s own extension points:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;code-line language-csharp&quot;&gt;[&lt;span class=&quot;hljs-meta&quot;&gt;InitializableModule&lt;/span&gt;]
[&lt;span class=&quot;hljs-meta&quot;&gt;ModuleDependency(typeof(EPiServer.Web.InitializationModule))&lt;/span&gt;]
&lt;span class=&quot;hljs-keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;sealed&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;hljs-title&quot;&gt;OptiA11yCmsModule&lt;/span&gt; : &lt;span class=&quot;hljs-title&quot;&gt;IConfigurableModule&lt;/span&gt;
{
    &lt;span class=&quot;hljs-function&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;hljs-title&quot;&gt;ConfigureContainer&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;ServiceConfigurationContext context&lt;/span&gt;)&lt;/span&gt;
    {
        context.Services.AddOptiA11y();
        context.Services.AddScoped&amp;lt;IPaasContentLoader, EPiServerPaasContentLoader&amp;gt;();
        context.Services.AddTransient&amp;lt;IStartupFilter, OptiA11yStartupFilter&amp;gt;();
        context.Services.AddControllersWithViews();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;code-line&quot;&gt;That&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;IStartupFilter&amp;nbsp;&lt;/span&gt;is the piece I like most, because it removes the last line of host code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;code-line language-csharp&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;internal&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;sealed&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;hljs-title&quot;&gt;OptiA11yStartupFilter&lt;/span&gt; : &lt;span class=&quot;hljs-title&quot;&gt;IStartupFilter&lt;/span&gt;
{
    &lt;span class=&quot;hljs-function&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;public&lt;/span&gt; Action&amp;lt;IApplicationBuilder&amp;gt; &lt;span class=&quot;hljs-title&quot;&gt;Configure&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;Action&amp;lt;IApplicationBuilder&amp;gt; next&lt;/span&gt;)&lt;/span&gt; =&amp;gt; app =&amp;gt;
    {
        next(app);
        app.UseEndpoints(endpoints =&amp;gt;
        {
            endpoints.MapRunAuditEndpoint();
            endpoints.MapRunAuditPanelEndpoint();
        });
    };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;code-line&quot;&gt;No&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;MapRunAuditEndpoint()&lt;/span&gt;&amp;nbsp;in the host&#39;s&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Program.cs&lt;/span&gt;. Two more attributes do the UI: a&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;[MenuProvider]&lt;/span&gt;&amp;nbsp;the shell discovers automatically for the &quot;Accessibility audit&quot; menu entry, and an iframe component plugin that puts a findings panel in the assets pane of the edit view, so an editor sees issues for the page they&#39;re on without leaving it. (The shell appends the current editor context as an&amp;nbsp;&lt;code&gt;id&lt;/code&gt;&amp;nbsp;query parameter and reloads the iframe on context change &amp;mdash; which is why the panel endpoint reads&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;id&lt;/span&gt;, falling back to&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;contentLink&lt;/span&gt;.)&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;The report view renders through&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;IRazorViewEngine&amp;nbsp;&lt;/span&gt;directly rather than requiring the host to register MVC controllers, is styled to sit inside the Optimizely admin chrome, and supports client-side filtering and sorting, including by confidence, so an editor can take the certain fails first and the judgement calls when they have time.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;The EPiServer types are confined to exactly one file,&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;EPiServerPaasContentLoader&lt;/span&gt;, which maps&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;IContent&amp;nbsp;&lt;/span&gt;/&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;XhtmlString&amp;nbsp;&lt;/span&gt;/&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;ContentArea&amp;nbsp;&lt;/span&gt;(recursing into nested blocks) onto the CMS-agnostic&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;PaasContentNode&lt;/span&gt;&amp;nbsp;/&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;PaasProperty&amp;nbsp;&lt;/span&gt;records. Everything below that line, &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;PaasContentAdapter&lt;/span&gt;, the parser, the engine, the rules, stays CMS-free.&lt;/p&gt;
&lt;h2 class=&quot;code-line&quot;&gt;The optional browser slice&lt;/h2&gt;
&lt;p class=&quot;code-line&quot;&gt;By default OptiA11y runs no browser at all, which is the right default: audits are fast, deterministic, and run anywhere. But it means contrast and readability rules only see inline&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;style=&quot;&quot;&amp;nbsp;&lt;/span&gt;attributes. Anything from a stylesheet, a class, or a theme is invisible, and five checks have no static signal to work from whatsoever, because they&#39;re about a &lt;em&gt;rendered&lt;/em&gt;&amp;nbsp;page.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;OptiA11y.Rendering&lt;/span&gt;&amp;nbsp;is a separately packaged, opt-in slice that drives headless Chromium via Playwright and, for a content item&#39;s preview URL:&lt;/p&gt;
&lt;ul class=&quot;code-line&quot;&gt;
&lt;li class=&quot;code-line&quot;&gt;reads computed CSS for every visible text node (colour, background, size, weight, alignment, and whether the background is an image or gradient);&lt;/li&gt;
&lt;li class=&quot;code-line&quot;&gt;measures the bounding box of every interactive element, and whether its appearance changes at all on focus - target size (2.5.8) and focus indicator (2.4.7);&lt;/li&gt;
&lt;li class=&quot;code-line&quot;&gt;scans for automatically-starting, infinitely-repeating CSS animations (2.2.2);&lt;/li&gt;
&lt;li class=&quot;code-line&quot;&gt;resizes to a 320px-equivalent viewport and checks for horizontal overflow (1.4.10);&lt;/li&gt;
&lt;li class=&quot;code-line&quot;&gt;injects the WCAG 1.4.12 reference text-spacing overrides and checks which text clips (1.4.12).&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;code-line&quot;&gt;The important part is how it plugs into the rules. It doesn&#39;t.&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;RunAuditHandler&amp;nbsp;&lt;/span&gt;appends the results as the&amp;nbsp;&lt;em&gt;same fragment shapes&lt;/em&gt;&amp;nbsp;the static parser produces, before the engine runs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;code-line language-csharp&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;var&lt;/span&gt; diagnostics = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; _renderedStyleProvider.CaptureAsync(previewUrl, cancellationToken);
&lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (diagnostics.IsEmpty)
{
    &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; document;
}

&lt;span class=&quot;hljs-keyword&quot;&gt;var&lt;/span&gt; renderedFragments = RenderedStyleFragmentBuilder.Build(diagnostics, location);
&lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; AuditDocument(document.ContentReference, document.Fragments.Concat(renderedFragments).ToList());
&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;code-line&quot;&gt;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;ColorContrastRule&amp;nbsp;&lt;/span&gt;and&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;TextReadabilityRule&amp;nbsp;&lt;/span&gt;need no knowledge that any of this happened. The five rendered-only rules simply find no fragments and produce nothing when the slice isn&#39;t installed.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;And the provider fails soft, without exception:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;code-line language-csharp&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;catch&lt;/span&gt;
{
    &lt;span class=&quot;hljs-comment&quot;&gt;// Rendering is a best-effort enrichment; any failure (browser unavailable,&lt;/span&gt;
    &lt;span class=&quot;hljs-comment&quot;&gt;// navigation timeout, unreachable URL) should not fail the whole audit.&lt;/span&gt;
    &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; RenderedPageDiagnostics.Empty;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;code-line&quot;&gt;A bare&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;catch&amp;nbsp;&lt;/span&gt;is usually a smell. Here it&#39;s the contract. A missing Chromium binary on a build agent, a preview URL behind auth, a page that never reaches network-idle, none of those are reasons an editor should see an error instead of the findings the static path already computed. Enrichment that can break the audit isn&#39;t enrichment.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;Opting in is two registrations, and the second is mandatory by design:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;code-line language-csharp&quot;&gt;services.AddOptiA11y();                 &lt;span class=&quot;hljs-comment&quot;&gt;// unaffected&lt;/span&gt;
services.AddOptiA11yRenderedStyles();   &lt;span class=&quot;hljs-comment&quot;&gt;// opt-in&lt;/span&gt;

&lt;span class=&quot;hljs-comment&quot;&gt;// Required: the default resolver returns null, which skips enrichment entirely.&lt;/span&gt;
services.AddSingleton&amp;lt;IContentPreviewUrlResolver, MyContentPreviewUrlResolver&amp;gt;();
&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;code-line&quot;&gt;Only the host knows how to build a preview URL for a content item, so the default resolver returns&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;null&amp;nbsp;&lt;/span&gt;and the whole slice no-ops rather than guessing.&lt;/p&gt;
&lt;h2 class=&quot;code-line&quot;&gt;What it deliberately does not do&lt;/h2&gt;
&lt;p class=&quot;code-line&quot;&gt;The non-goals list is short and I intend to keep it:&lt;/p&gt;
&lt;ul class=&quot;code-line&quot;&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;No crawler.&lt;/strong&gt;&amp;nbsp;Property-level analysis of content an editor owns, not site-wide scanning.&lt;/li&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;No accessibility score.&lt;/strong&gt;&amp;nbsp;A number invites optimising the number.&lt;/li&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;No automated fixing.&lt;/strong&gt;&amp;nbsp;Every heuristic finding is a judgement call; auto-fixing judgement calls at scale is how you get ten thousand images described as &quot;image&quot;.&lt;/li&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;No publish blocking.&lt;/strong&gt;&amp;nbsp;A warning on publish is on the roadmap (warn-only, severity configurable). A gate is not.&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;code-line&quot;&gt;And the headline commitment, stated in the README and worth repeating: a clean OptiA11y result means OptiA11y found no issues&amp;nbsp;&lt;strong&gt;it was able to detect&lt;/strong&gt;. It is not a legal position and should never be shown to a client, auditor, or regulator as proof of WCAG or ADA conformance. It&#39;s a second pair of eyes for editors, not a substitute for a professional audit.&lt;/p&gt;
&lt;h2 class=&quot;code-line&quot;&gt;Where it is, and what&#39;s next&lt;/h2&gt;
&lt;p class=&quot;code-line&quot;&gt;Version 0.3.1, targeting .NET 8 and .NET 10. Four packages (&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;OptiA11y.Core, OptiA11y.Rendering, OptiA11y.Cms, OptiA11y.Cms12&lt;/span&gt;) versioned and shipped together, 220 test methods across four test projects, and a sample host seeded with deliberately broken content, including a nested block &amp;mdash; so you can&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;&amp;nbsp;dotnet run&lt;/span&gt;&amp;nbsp;and hit&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;GET /optia11y/audit/page-home&lt;/span&gt;&amp;nbsp;without a CMS anywhere in sight.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;Four slices are done: single-item audit with deep linking, the self-registering CMS 12/13 add-on, the rule expansion, and the optional rendering slice. Next up:&lt;/p&gt;
&lt;ol class=&quot;code-line&quot;&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;Issue register&lt;/strong&gt; - persisted findings across content, filterable, with status. EF Core, following the Stott Security pattern rather than DDS, for fewer host assumptions and a cleaner uninstall. Until then, &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;RunAuditHandler&lt;/span&gt;&amp;nbsp;holds no persistence at all, deliberately.&lt;/li&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;Dismissal with a recorded reason&lt;/strong&gt; - who, when, why. If the tool is going to flag judgement calls, dismissing one has to be a first-class, auditable act rather than a checkbox.&lt;/li&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;Publish warning&lt;/strong&gt; - content event handler, warn only, severity configurable.&lt;/li&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;SaaS adapter&lt;/strong&gt; - scaffolded and covered by the equivalence suite, not yet wired to a real tenant.&lt;/li&gt;
&lt;/ol&gt;
&lt;p class=&quot;code-line&quot;&gt;Three things I&#39;m still honestly unsure about, all listed in the repo: whether the Razor Class Library plus menu provider add-on pattern holds across future CMS 12/13 minors; whether DDS remains available (moot until persistence lands, but it decides whether the EF Core choice is a preference or a necessity); and correlating rendered DOM text nodes back to a specific&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;SourceLocation&lt;/span&gt;, which is currently matched on trimmed visible text and will be ambiguous on a page with repeated identical strings.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;The code is at&amp;nbsp;&lt;a href=&quot;https://github.com/adayinthelifeofapro/OptiA11y&quot;&gt;github.com/adayinthelifeofapro/OptiA11y&lt;/a&gt;. If you take one idea from it, make it the confidence split, most tools in this space would be more useful if they were more willing to say &quot;I&#39;m not sure, look at this.&quot;&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;div class=&quot;author__image col-sm-5&quot;&gt;&lt;img class=&quot;clickable&quot; title=&quot;Click image to zoom&quot; src=&quot;/link/e6418e9a3a8743e69f1390b78628d8ef.aspx&quot; alt=&quot;&quot; width=&quot;288&quot; height=&quot;270&quot; /&gt;&lt;/div&gt;
&lt;div class=&quot;author__content col-sm-7 keep-margin-bottom&quot;&gt;
&lt;h2&gt;Graham Carr, Technical Architect&lt;/h2&gt;
&lt;div class=&quot;rich-text aos-init aos-animate&quot;&gt;
&lt;p&gt;I am an experienced Technical Architect with over 30 years&amp;rsquo; experience in a wide range of products and technologies. I have helped companies deliver their digital vision from concept all the way through to delivery. I have a particular passion for DXPs (Digital Experience Platforms) and am a certified developer for Optimizely as well as a Platinum OMVP.&lt;/p&gt;
&lt;p&gt;You can also follow me on&amp;nbsp;&lt;a href=&quot;https://adayinthelife.pro/&quot;&gt;https://adayinthelife.pro&lt;/a&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;</description>            <guid>https://world.optimizely.com/blogs/allthingsopti/dates/2026/9/a-day-in-the-life-of-an-optimizely-omvp-optia11y-accessibility-help-for-optimizely-editors-that-refuses-to-lie-to-them/</guid>            <pubDate>Tue, 15 Sep 2026 10:42:54 GMT</pubDate>           <category>Blog post</category></item><item> <title>OptiPowerTools.ScheduledJobsInsights: Execution History for Optimizely’s Native Scheduled Jobs</title>            <link>https://szolkowski.github.io/2026/09/15/optipowertools-scheduledjobsinsights-execution-history-for-optimizely-scheduled-jobs</link>            <description>A drop-in base class and Blazor UI that records what Optimizely CMS 13 scheduled jobs actually did — logs, metrics, result summaries, and retention.</description>            <guid>https://szolkowski.github.io/2026/09/15/optipowertools-scheduledjobsinsights-execution-history-for-optimizely-scheduled-jobs</guid>            <pubDate>Tue, 15 Sep 2026 08:00:00 GMT</pubDate>           <category>Blog post</category></item><item> <title>A day in the life of an Optimizely OMVP: Commerce Connect 15 isn&#39;t an upgrade. It&#39;s a reset.</title>            <link>https://world.optimizely.com/blogs/allthingsopti/dates/2026/6/a-day-in-the-life-of-an-optimizely-omvp-commerce-connect-15-isnt-an-upgrade.-its-a-reset</link>            <description>&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;The version number is doing a lot of work to look harmless. Fourteen to fifteen. A nudge, surely. Bump the packages, run the build, ship it Friday.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;It is not that. If you&#39;ve shipped a few of these you can feel it coming, the kind of release where the changelog is mostly the word &quot;removed&quot; and the real project plan lives in everything that&amp;nbsp;&lt;em&gt;doesn&#39;t&lt;/em&gt; compile afterwards.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;Here&#39;s the practitioner read on what actually changed, and where the work hides.&lt;br /&gt;&lt;br /&gt;&lt;img src=&quot;/link/af9c022b38b342b58f181fed62a7d3b1.aspx&quot; alt=&quot;&quot; width=&quot;1000&quot; height=&quot;667&quot; /&gt;&lt;/p&gt;
&lt;h2 class=&quot;text-text-100 mt-3 -mb-1 text-[1.125rem] font-bold&quot;&gt;The headline nobody puts in the headline&lt;/h2&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;Commerce Connect 15 doesn&#39;t travel alone. You can&#39;t take Commerce to 15 without taking CMS to 13 at the same time. They&#39;re welded together. So the moment you commit to 15, you&#39;ve also signed up for .NET 10, mandatory Optimizely Graph, mandatory Opti ID, and Visual Builder replacing on-page editing.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;That&#39;s the bit to land with stakeholders early. &quot;We&#39;re upgrading Commerce&quot; undersells it. You&#39;re moving the whole platform foundation in one go. Scope it as a programme, not a package update, and the rest of the conversation gets a lot easier.&lt;/p&gt;
&lt;h2 class=&quot;text-text-100 mt-3 -mb-1 text-[1.125rem] font-bold&quot;&gt;Find is gone. Plan for it.&lt;/h2&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;Search &amp;amp; Navigation &amp;rarr; Find does not exist in this world. There&#39;s no .NET 8 escape hatch and no quiet &quot;Find still works for now.&quot; It&#39;s replaced by a commerce-specific Graph search provider (&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Optimizely.Graph.Commerce&lt;/span&gt;) that powers the catalog panel, global search, and the order-management line-item lookup.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;Think of it like swapping the engine while the car&#39;s still on the drive. The pedals are in the same place &amp;mdash; the CMS 13 Graph SDK gives you a fluent &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Where()&lt;/span&gt; / SearchFor() / &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Limit()&lt;/span&gt; API that reads a lot like Find did, so the muscle memory transfers. But underneath, your indexing, your queries, and anything clever you built on &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;EPiServer.Find.Commerce&lt;/span&gt; all need rethinking. If your catalog search is load-bearing, and on commerce it always is. This is your single biggest architectural item. Treat it as such.&lt;/p&gt;
&lt;h2 class=&quot;text-text-100 mt-3 -mb-1 text-[1.125rem] font-bold&quot;&gt;Where the real hours go&lt;/h2&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;Three places. Every time.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;&lt;strong&gt;The workflow engine is gone.&lt;/strong&gt; &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Mediachase.Commerce.Workflow&lt;/span&gt; and the old &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;ActivityFlow &lt;/span&gt;engine have been pulled. Anything calling &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;RunWorkflow()&lt;/span&gt;, &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;OrderGroupWorkflowManager&lt;/span&gt;, or &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;OrderStatusManager &lt;/span&gt;moves to the processor APIs &amp;mdash; &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;IPaymentProcessor&lt;/span&gt;, &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;IPurchaseOrderProcessor&lt;/span&gt;, &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;IShipmentProcessor&lt;/span&gt;. This is the work that passes the compiler and then bites you at runtime, because order state transitions are exactly the thing your test coverage tends to be thin on. Budget for it and test the full cart &amp;rarr; promotion &amp;rarr; checkout &amp;rarr; payment &amp;rarr; fulfilment path like your bonus depends on it.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;&lt;strong&gt;Credit-card storage is gone.&lt;/strong&gt; &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;ICreditCardPayment&lt;/span&gt;, the &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;CreditCard &lt;/span&gt;class, the lot, removed on PCI grounds. If you were storing card data (and you shouldn&#39;t have been), you&#39;re now tokenising through Stripe / Adyen / PayPal and hanging tokens off &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;IPayment&lt;/span&gt;. Mostly a good-news story; occasionally a nasty surprise in an older codebase.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;&lt;strong&gt;Serialization moved.&lt;/strong&gt; Newtonsoft.Json out, System.Text.Json in, matching CMS 13. If you wrote custom converters into the Commerce pipeline, they need rewriting, &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;ReadJson&lt;/span&gt;/&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;WriteJson &lt;/span&gt;become &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Read&lt;/span&gt;/&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Write &lt;/span&gt;over &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Utf8JsonReader&lt;/span&gt;/&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Utf8JsonWriter&lt;/span&gt;. And Newtonsoft is no longer riding along as a transitive dependency, so if your own code leaned on it, reference it explicitly before it disappears out from under you.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;There&#39;s also 50-odd deprecated methods finally being shown the door, plus the usual constructor-signature changes. None of it individually is hard. Collectively, on a mature build, it adds up.&lt;/p&gt;
&lt;h2 class=&quot;text-text-100 mt-3 -mb-1 text-[1.125rem] font-bold&quot;&gt;The genuinely new bits&lt;/h2&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;It&#39;s not all subtraction. &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;IOrderRepository &lt;/span&gt;finally gets a proper async surface,&amp;nbsp; &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;LoadAsync&lt;/span&gt;, &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;SaveAsync&lt;/span&gt;, &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;SaveAsPurchaseOrderAsync&lt;/span&gt;, the lot, all with &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;CancellationToken&lt;/span&gt;. Sync stays for compatibility, but new code should go async from day one.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;And the direction of travel is obvious once you stand back. Catalog entries are now CMS content types, which means they inherit Visual Builder, Content Manager&#39;s search-first editing, and, the actual point of all this, Opal. There&#39;s already a Product Promotion agent that builds and configures promotions, assigns products and variants, and applies promotion types for you. Catalog data as content, delivered through Graph, operated by agents. That&#39;s the whole thesis. Fifteen is the version where it stops being a slide and starts being the product.&lt;/p&gt;
&lt;h2 class=&quot;text-text-100 mt-3 -mb-1 text-[1.125rem] font-bold&quot;&gt;So what do you actually do&lt;/h2&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;Same play we run every time, in order:&lt;/p&gt;
&lt;ol 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-decimal flex flex-col gap-1 pl-8 mb-3&quot;&gt;
&lt;li class=&quot;font-claude-response-body whitespace-normal break-words pl-2&quot;&gt;&lt;strong&gt;Assess on 14.&lt;/strong&gt; Inventory your &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;.csproj&lt;/span&gt; files, every &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;EPiServer.*&lt;/span&gt; reference, every add-on, and every deprecated API you&#39;re still calling. Fail the build on those warnings now.&lt;/li&gt;
&lt;li class=&quot;font-claude-response-body whitespace-normal break-words pl-2&quot;&gt;&lt;strong&gt;Clear the obsoletes while you&#39;re still on 14.&lt;/strong&gt; Most of them are &lt;em&gt;removed&lt;/em&gt; in 15, so fixing them early turns a wall of compiler errors into a handful.&lt;/li&gt;
&lt;li class=&quot;font-claude-response-body whitespace-normal break-words pl-2&quot;&gt;&lt;strong&gt;Do the coupled jump in a branch.&lt;/strong&gt; .NET 10, CMS 13, Commerce 15, and pin to the latest 15.x patch, not 15.0.0. Wire up Graph and Opti ID. Rewrite the converters. Move order processing to the processor APIs.&lt;/li&gt;
&lt;li class=&quot;font-claude-response-body whitespace-normal break-words pl-2&quot;&gt;&lt;strong&gt;Test the order pipeline hardest.&lt;/strong&gt; It&#39;s the thing that compiles clean and breaks live.&lt;/li&gt;
&lt;/ol&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;One more, and it&#39;s the one that quietly sinks timelines: &lt;strong&gt;the add-on long tail.&lt;/strong&gt; At and just after GA, the connectors were still rolling out, ODP, Service API, PIM. Check the NuGet server for &lt;em&gt;your&lt;/em&gt; specific add-ons before you commit a date to anyone. A green build on the core packages means nothing if the connector your client lives on hasn&#39;t shipped yet.&lt;/p&gt;
&lt;hr class=&quot;border-border-200 border-t-0.5 my-3 mx-1.5&quot; /&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;None of this is a reason to flinch. It&#39;s a reason to plan properly. The technical upgrade is increasingly a solved exercise, we&#39;re encoding chunks of it into agent skills already. The craft is in the content-model work, the order pipeline, and refusing to let &quot;it&#39;s just a version bump&quot; anywhere near the estimate.&lt;/p&gt;
&lt;p class=&quot;font-claude-response-body break-words whitespace-normal leading-[1.7]&quot;&gt;Fifteen is a good release. Just give it the respect a reset deserves.&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;div class=&quot;author__image col-sm-5&quot;&gt;&lt;img class=&quot;clickable&quot; title=&quot;Click image to zoom&quot; src=&quot;/link/e6418e9a3a8743e69f1390b78628d8ef.aspx&quot; alt=&quot;&quot; width=&quot;288&quot; height=&quot;270&quot; /&gt;&lt;/div&gt;
&lt;div class=&quot;author__content col-sm-7 keep-margin-bottom&quot;&gt;
&lt;h2&gt;Graham Carr, Technical Architect&lt;/h2&gt;
&lt;div class=&quot;rich-text aos-init aos-animate&quot;&gt;
&lt;p&gt;I am an experienced Technical Architect with over 30 years&amp;rsquo; experience in a wide range of products and technologies. I have helped companies deliver their digital vision from concept all the way through to delivery. I have a particular passion for DXPs (Digital Experience Platforms) and am a certified developer for Optimizely as well as a Platinum OMVP.&lt;/p&gt;
&lt;p&gt;You can also follow me on&amp;nbsp;&lt;a href=&quot;https://adayinthelife.pro/&quot;&gt;https://adayinthelife.pro&lt;/a&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;</description>            <guid>https://world.optimizely.com/blogs/allthingsopti/dates/2026/6/a-day-in-the-life-of-an-optimizely-omvp-commerce-connect-15-isnt-an-upgrade.-its-a-reset</guid>            <pubDate>Mon, 14 Sep 2026 18:49:06 GMT</pubDate>           <category>Blog post</category></item><item> <title>Shareable stakeholder previews for Optimizely SaaS CMS</title>            <link>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2026/9/shareable-stakeholder-previews-for-optimizely-saas-cms/</link>            <description>&lt;p&gt;In August,&amp;nbsp;&lt;span style=&quot;color: rgb(45, 194, 107);&quot;&gt;&lt;a class=&quot;text-accent hover:underline underline-offset-[1px] focus-visible:outline-hidden hide-focus-ring focus-visible:shadow-focus rounded-[3px]&quot; style=&quot;color: rgb(45, 194, 107);&quot; href=&quot;https://blog.nikkipunjabi.com/2026/08/shareable-stakeholder-previews-optimizely-saas-cms.html&quot;&gt;Nikki Punjabi wrote about a gap in Optimizely SaaS CMS&lt;/a&gt;&lt;/span&gt;: there is no out-of-the-box way to share a draft page with someone outside the CMS. The editor preview is session-bound. If a legal reviewer or brand owner without a CMS account needs to approve a page, you have to build that yourself.&lt;/p&gt;
&lt;p&gt;The post set out a design. The link should be a signed &quot;permission slip&quot; rather than a credential: it grants access to one draft page until an expiry date. Links should be internal-only by default, with external sharing an explicit choice. No database should be needed, CMS keys should stay on the server, and drafts should be hidden from search engines.&lt;/p&gt;
&lt;p&gt;We built that design as a CMS UI Extension on the Optimizely Connect Platform (OCP). This post covers how.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/53c16214073e477fb71d655c2d73abac.aspx&quot; alt=&quot;The Share preview panel in the CMS editor sidebar&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Why a CMS UI Extension&lt;/h2&gt;
&lt;p&gt;Authors should create the link next to the page they are editing, not in a separate admin tool. That is what CMS UI Extensions do: an OCP app can register a sidebar panel inside the editing view. The panel is told which content item is open (key, version, locale) and updates as the editor moves between pages.&lt;/p&gt;
&lt;p&gt;The same app also carries its backend functions, a settings screen and storage, so the whole feature ships as one unit. The manifest declares everything:&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot;&gt;&lt;code&gt;functions:
  cms_extension:
    entry_point: CmsUiExtension
    accepts: cms_ui_extension     # callable only from the panel
  preview:
    entry_point: Preview
    accepts: http                 # public, reviewer-facing endpoint

ui_extensions:
  sidebar:
    - name: share-preview
      entry_point: SharePreview
      display_name: Share preview&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The panel is a React component registered through &lt;em&gt;@optimizely/cms-extensibility-sdk&lt;/em&gt;:&lt;/p&gt;
&lt;pre class=&quot;language-jsx&quot;&gt;&lt;code&gt;register((context) =&amp;gt; (
  &amp;lt;AxiomProvider&amp;gt;
    &amp;lt;SharePreviewPanel context={context} /&amp;gt;
  &amp;lt;/AxiomProvider&amp;gt;
));&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;context.content.subscribe()&lt;/em&gt; gives the panel the open page; &lt;em&gt;context.extension.invokeFunction()&lt;/em&gt; calls the app&#39;s backend. We built the UI with Optiaxiom, Optimizely&#39;s component library, so the panel matches the rest of the CMS.&lt;/p&gt;
&lt;p&gt;That is the full setup: a manifest, a React entry point, a backend class, and &lt;em&gt;ocp app prepare --publish&lt;/em&gt; to deploy to all regions.&lt;/p&gt;
&lt;h2&gt;The link&lt;/h2&gt;
&lt;p&gt;The link contains an HMAC-SHA256 signed token over six fields:&lt;/p&gt;
&lt;pre class=&quot;language-javascript&quot;&gt;&lt;code&gt;{ contentKey, version, locale, scope, iat, exp }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There are no credentials in it and no session behind it. When a reviewer opens the link, the server recomputes the signature, checks the expiry against its own clock and enforces the scope. Editing the URL to extend the expiry or change the scope breaks the signature.&lt;/p&gt;
&lt;p&gt;Internal links only open from configured office or VPN IP ranges. If no ranges are configured, they refuse rather than falling back to shareable behaviour. Shareable is a per-link choice and the panel warns you what it means.&lt;/p&gt;
&lt;p&gt;The other guardrails from Nikki&#39;s design are all present: &lt;em&gt;noindex&lt;/em&gt; via meta tag and &lt;em&gt;X-Robots-Tag&lt;/em&gt;, &lt;em&gt;no-store&lt;/em&gt; caching, read-only rendering, a clear page for expired links, and a hard refusal when the app is unconfigured. If the page has been published since the link was created, the link redirects to the live page.&lt;/p&gt;
&lt;h2&gt;Rendering the real page&lt;/h2&gt;
&lt;p&gt;A metadata summary of the draft is not enough; stakeholders want the page as designed, and in a headless setup only the front end can render it.&lt;/p&gt;
&lt;p&gt;Our first attempt was to mint Optimizely Graph tokens from the app, both self-signed with the Graph secret and via an OAuth exchange. Graph rejected both. The documentation is clear once you read it properly: bearer tokens must come from an OIDC authorisation server.&lt;/p&gt;
&lt;p&gt;The working approach came from &lt;a href=&quot;https://github.com/episerver/cms-saas-vercel-demo&quot;&gt;Optimizely&#39;s own SaaS demo site&lt;/a&gt;, which includes a route at &lt;em&gt;/.well-known/optimizely/preview-psk&lt;/em&gt; for exactly this purpose. You send &lt;em&gt;key&lt;/em&gt;, &lt;em&gt;ver&lt;/em&gt; and &lt;em&gt;loc&lt;/em&gt; plus an HMAC signature over those parameters, keyed with a pre-shared token the head already holds. The head validates the signature, fetches the draft with its own server-side Graph credentials, and renders it with its real components. No CMS or Graph credential ever reaches the browser.&lt;/p&gt;
&lt;p&gt;That stock route has one problem for this use case: the signature never expires, so the URL a reviewer lands on works forever. We added a sibling route that includes an &lt;em&gt;exp&lt;/em&gt; timestamp in the signed data and refuses once it has passed. Our app issues those redirects freshly signed on each visit, valid for ten minutes. The link the author shares remains the single entry point where expiry, scope and revocation are checked.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/5db6996acdcf47beb0cd94d8b5e108f2.aspx&quot; alt=&quot;The reviewer view: the real page rendered by the front end, reached through a shareable link&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;The key-value store&lt;/h2&gt;
&lt;p&gt;The first iteration stored nothing, as the original design suggested. Two requirements changed that: authors wanted to see a page&#39;s existing links after a refresh, and to revoke one.&lt;/p&gt;
&lt;p&gt;OCP apps have a key-value store with atomic read-modify-write and per-row TTL. We keep one row per content item. Each write prunes expired entries and resets the row&#39;s TTL to the furthest expiry, so the platform deletes the row once the last link dies. The panel filters by expiry on read, so expired links drop out of the list immediately.&lt;/p&gt;
&lt;p&gt;Two details are worth copying:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The KV store documentation says not to store tokens, and our URLs contain one. HMAC tokens are deterministic, so the registry stores only metadata (id, version, locale, scope, issued and expiry times) and the app re-signs the URL when listing. No sensitive value is ever stored.&lt;/li&gt;
&lt;li&gt;Revoking moves a link&#39;s entry to a deny-list in the same row. The public preview function checks the incoming token&#39;s payload against that list. The token format is unchanged, so earlier links are revocable too. A revoke that hits a storage error fails loudly; a viewer request that cannot read the registry fails closed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;/link/96984f6cc7af432b8c36b55dd0db869c.aspx&quot; alt=&quot;Share preview panel showing the Active links table with version, scope, expiry, copy and revoke&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Configuration&lt;/h2&gt;
&lt;p&gt;Everything above is driven by install-time settings, and OCP generates the settings screen for you. The fields are declared in a YAML form definition in the app (&lt;em&gt;forms/settings.yml&lt;/em&gt;); a lifecycle hook validates and saves them into the app&#39;s settings store, which is the right home for credentials, unlike the key-value store. The app never needs its own admin UI.&lt;/p&gt;
&lt;p&gt;The settings map directly to the decisions Nikki&#39;s post said to make before building:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Signing secret&lt;/strong&gt;: leave it blank and the app generates one on save. Rotating it voids every link.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Optimizely Graph credentials&lt;/strong&gt;: used by the server to fetch drafts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Front-end preview route and pre-shared key&lt;/strong&gt;: for rendering the real page.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Live site base URL&lt;/strong&gt;: published pages redirect here.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Internal network ranges&lt;/strong&gt;: defines what &quot;internal&quot; means.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Default link expiry&lt;/strong&gt;: used when the author doesn&#39;t pick one.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;/link/7ca2ff4301ef4e3ca68e2ccd181e4f18.aspx&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;What it offers&lt;/h2&gt;
&lt;p&gt;For authors: a panel next to the page with an audience choice (Internal by default, Shareable deliberately), an expiry choice (1, 3 or 7 days), one click to create, copy and send. The table of active links survives refreshes, shows which draft version each link was pinned to, and revokes per row with a confirm step.&lt;/p&gt;
&lt;p&gt;For reviewers: a link that opens without an account or login, showing the real page rendered by the front end. Expired and revoked links get clear explanations.&lt;/p&gt;
&lt;p&gt;For the organisation: link creation and revocation are logged with content key, version, scope and expiry. Rotating the signing secret voids every link at once. CMS and Graph credentials never leave the server.&lt;/p&gt;
&lt;h2&gt;Closing&lt;/h2&gt;
&lt;p&gt;External preview links are a small feature that touches security, caching, indexing and publishing policy, and a SaaS CMS UI Extension handled all of it in one deployable app. If your editors need something the CMS does not ship, this is a good route to it.&lt;/p&gt;
&lt;p&gt;Thanks to Nikki Punjabi for the original design.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/Minesh-Shah/Dates/2026/9/shareable-stakeholder-previews-for-optimizely-saas-cms/</guid>            <pubDate>Mon, 14 Sep 2026 13:52:54 GMT</pubDate>           <category>Blog post</category></item><item> <title>A day in the life of an Optimizely OMVP: Scoping a Search &amp; Navigation - Optimizely Graph migration with Roslyn</title>            <link>https://world.optimizely.com/blogs/allthingsopti/dates/2026/9/a-day-in-the-life-of-an-optimizely-omvp-scoping-a-search--navigation---optimizely-graph-migration-with-roslyn/</link>            <description>&lt;p class=&quot;code-line&quot;&gt;If you have an Optimizely solution of any real age, you have Find code. Not in one tidy&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;SearchService.cs&lt;/span&gt;, either, it&#39;s spread through page controllers, view components, scheduled jobs, and that one static helper somebody wrote in 2019 that everything quietly depends on.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;At some point a conversation starts about moving to Optimizely Graph, and someone asks the question that decides the whole project:&lt;/p&gt;
&lt;blockquote class=&quot;code-line&quot;&gt;
&lt;p class=&quot;code-line&quot;&gt;How big is this, actually?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p class=&quot;code-line&quot;&gt;The honest answer is usually &quot;we don&#39;t know yet.&quot; So somebody greps for&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;.Search&amp;lt;&lt;/span&gt;, counts the hits, multiplies by a gut-feel number, and that becomes the estimate. I&#39;ve watched that estimate be wrong by a factor of three, in both directions.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;I built&amp;nbsp;&lt;strong&gt;OptiGraphMigrator&lt;/strong&gt;&amp;nbsp;to replace the guess with a report. It&#39;s a Roslyn analyzer and&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;dotnet&lt;/span&gt; CLI tool that reads your solution semantically, finds every Search &amp;amp; Navigation call, maps each one to its Optimizely Graph equivalent, and, the important part, tells you which ones &lt;em&gt;have no equivalent at all&lt;/em&gt;.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;&lt;img src=&quot;/link/e351cc3a995f454399993ccbd27de20d.aspx&quot; /&gt;&lt;/p&gt;
&lt;hr class=&quot;code-line&quot; /&gt;
&lt;h2 class=&quot;code-line&quot;&gt;Why grep doesn&#39;t work here&lt;/h2&gt;
&lt;p class=&quot;code-line&quot;&gt;Two reasons.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;&lt;strong&gt;The first is that Find queries are fluent chains, and the interesting part is usually at the end.&lt;/strong&gt;&amp;nbsp;Consider:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;code-line language-csharp&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;var&lt;/span&gt; query = _client.Search&amp;lt;Article&amp;gt;();

&lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (applyGeoFilter)
{
    query = query.OrderBy(a =&amp;gt; a.Title).DistanceFrom(latitude, longitude);
}

&lt;span class=&quot;hljs-keyword&quot;&gt;var&lt;/span&gt; results = query.GetContentResult();
&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;code-line&quot;&gt;Grepping for&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Search&amp;lt;&lt;/span&gt;&amp;nbsp;finds line 1 and tells you nothing. The thing that will actually derail your migration is&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;DistanceFrom&lt;/span&gt; - a geo/spatial filter - three lines later, inside a conditional, attached to a variable. A text search finds the string but can&#39;t tell you it belongs to the same query, or what content type it&#39;s filtering.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;OptiGraphMigrator resolves that chain through Roslyn&#39;s semantic model. It walks from the outermost invocation back down through the receivers, and it follows local-variable reassignment, so the example above is understood as&amp;nbsp;&lt;strong&gt;one&lt;/strong&gt; query with a geo filter on it, not three unrelated fragments.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;&lt;strong&gt;The second reason is that &quot;found a Find call&quot; is not useful information.&lt;/strong&gt;&amp;nbsp;What you need to know is what happens when you try to port it. Which brings me to the part of the design I think actually matters.&lt;/p&gt;
&lt;hr class=&quot;code-line&quot; /&gt;
&lt;h2 class=&quot;code-line&quot;&gt;Three buckets, not one number&lt;/h2&gt;
&lt;p class=&quot;code-line&quot;&gt;Every rule in the tool classifies a construct into one of three levels of&amp;nbsp;&lt;em&gt;translatability&lt;/em&gt;:&lt;/p&gt;
&lt;table class=&quot;code-line&quot;&gt;
&lt;thead class=&quot;code-line&quot;&gt;
&lt;tr class=&quot;code-line&quot;&gt;
&lt;th&gt;Bucket&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;What it costs you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody class=&quot;code-line&quot;&gt;
&lt;tr class=&quot;code-line&quot;&gt;
&lt;td&gt;&lt;strong&gt;Exact&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A mechanical, behaviour-preserving 1:1 mapping exists.&lt;/td&gt;
&lt;td&gt;Hours. Sometimes automatic.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&quot;code-line&quot;&gt;
&lt;td&gt;&lt;strong&gt;Caveat&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A mapping exists, but the semantics differ.&lt;/td&gt;
&lt;td&gt;A developer has to read it and decide.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&quot;code-line&quot;&gt;
&lt;td&gt;&lt;strong&gt;Blocked&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No clean translation. The feature must be redesigned.&lt;/td&gt;
&lt;td&gt;A conversation with your product owner.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p class=&quot;code-line&quot;&gt;&lt;br /&gt;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Filter(), OrderBy(), Skip(), Take(), GetContentResult() &lt;span style=&quot;color: rgb(255, 255, 255);&quot;&gt;-&lt;/span&gt;&lt;/span&gt;&amp;nbsp;these are&amp;nbsp;&lt;strong&gt;Exact&lt;/strong&gt;. They map onto Graph&#39;s&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;where,&amp;nbsp;orderBy,&amp;nbsp;skip,&amp;nbsp;limit&lt;/span&gt;&amp;nbsp;and query execution almost mechanically. If your codebase is 90% this, your migration is a port and your estimate should be small.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Language(),&amp;nbsp;FilterForVisitor(),&amp;nbsp;Select()&lt;/span&gt;&amp;nbsp;projections,&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;MatchContained()&lt;/span&gt; - these are &lt;strong&gt;Caveat&lt;/strong&gt;. There&#39;s a target to aim at, but the behaviour isn&#39;t identical, so a human has to confirm each one. Find&#39;s&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;For()&lt;/span&gt;&amp;nbsp;free-text search is in here too: it applies BM25-style ranking across all indexed fields, and Graph&#39;s full-text match will rank results differently. The code compiles either way. Whether the search results still look right is a different question.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;And then there&#39;s&amp;nbsp;&lt;strong&gt;Blocked&lt;/strong&gt;:&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;BestBets(),&amp;nbsp;DidYouMean(),&amp;nbsp;Autocomplete(),&amp;nbsp;MoreLikeThis(),&amp;nbsp;RemoveDuplicates(),&amp;nbsp;MinScore(),&amp;nbsp;DistanceFrom()&lt;/span&gt;, the statistical aggregations (&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;AverageOf/SumOf/MaximumOf/MinimumOf&lt;/span&gt;), custom scoring via&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Boost()/CustomScore()&lt;/span&gt;, query-time synonyms, and direct push-based indexing (&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Index()/UpdateIndex()/DeleteIndex()&lt;/span&gt;).&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;&lt;strong&gt;The Blocked list is the real output of this tool.&lt;/strong&gt;&amp;nbsp;Everything else is work you can plan. The Blocked list is work you have to&amp;nbsp;&lt;em&gt;design&lt;/em&gt;, and it&#39;s the reason migrations slip. Finding out in week six that the editorial team depends on Best Bets is considerably worse than finding out on day one.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;There&#39;s a fourth category worth mentioning: patterns that are technically legal Find but which only work because Find can fall back to evaluating them outside the index. A&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Filter()&lt;/span&gt;&amp;nbsp;whose lambda calls arbitrary .NET code is the classic case. Graph&#39;s&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;where&lt;/span&gt;&amp;nbsp;clauses can only express what the index itself can evaluate, so that logic has to become either an indexed field comparison or an explicit post-query filter in application code. The tool flags those specifically rather than letting them hide inside the friendly-looking &quot;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Filter()&lt;/span&gt;&amp;nbsp;&amp;rarr;&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;where&lt;/span&gt;&quot; mapping.&lt;/p&gt;
&lt;hr class=&quot;code-line&quot; /&gt;
&lt;h2 class=&quot;code-line&quot;&gt;What it looks like&lt;/h2&gt;
&lt;p class=&quot;code-line&quot;&gt;Point it at a solution or a project:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;code-line language-powershell&quot;&gt;optigraph&lt;span class=&quot;hljs-literal&quot;&gt;-migrate&lt;/span&gt; scan MySolution.sln &lt;span class=&quot;hljs-literal&quot;&gt;--format&lt;/span&gt; console
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;code-line&quot;&gt;WARNING OGM001   FindUsageSamples.cs(39,27): Find &#39;Filter(...)&#39; can be translated to a Graph &#39;where&#39; clause
        -&amp;gt; Graph equivalent: .Where(x =&amp;gt; &amp;lt;translated predicate&amp;gt;)
WARNING OGM003   FindUsageSamples.cs(39,27): Find &#39;OrderBy(...)&#39; can be translated to a Graph &#39;orderBy&#39; clause
        -&amp;gt; Graph equivalent: .OrderBy(x =&amp;gt; &amp;lt;field&amp;gt;)
INFO    OGM007   FindUsageSamples.cs(39,27): Find &#39;Take(...)&#39; can be translated to a Graph &#39;limit&#39; argument
        -&amp;gt; Graph equivalent: .Take(&amp;lt;count&amp;gt;)
WARNING OGM010   FindUsageSamples.cs(82,27): Find &#39;StaticallyCacheFor(...)&#39; has no built-in Graph SDK equivalent; caching must be implemented by the caller
        -&amp;gt; Graph equivalent: Cache the GraphQL response in the calling application (for example via IMemoryCache) for an equivalent duration
WARNING OGM103   FindUsageSamples.cs(114,13): Find indexing convention &#39;ShouldIndex(...)&#39; has no in-code Graph equivalent; configure the schema in Graph instead
WARNING OGM104   FindUsageSamples.cs(123,26): &#39;FindByContentType(...)&#39; is an IContentLoader helper backed by Find; migrate the call site to the Graph SDK client
WARNING OGM202   FindUsageSamples.cs(131,27): Find custom scoring &#39;Boost(...)&#39; has no clean Graph SDK translation

Summary: 0 error(s), 48 warning(s), 44 info
&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;code-line&quot;&gt;The Markdown format is the one to hand to a lead or put in a ticket, because it leads with the shape of the problem rather than the list of findings:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;code-line language-markdown&quot;&gt;&lt;span class=&quot;hljs-section&quot;&gt;# OptiGraphMigrator report&lt;/span&gt;

0 error(s), 48 warning(s), 44 info

&lt;span class=&quot;hljs-bullet&quot;&gt;-&lt;/span&gt; Exact (auto-fixable): 48
&lt;span class=&quot;hljs-bullet&quot;&gt;-&lt;/span&gt; Caveat (needs review): 19
&lt;span class=&quot;hljs-bullet&quot;&gt;-&lt;/span&gt; Blocked (no clean translation): 15

&lt;span class=&quot;hljs-section&quot;&gt;## Top blocking patterns&lt;/span&gt;

| Rule | Title | Occurrences |
|---|---|---|
| OGM202 | Custom relevance scoring has no Graph equivalent | 2 |
| OGM021 | DistanceFrom(...) geo/spatial filter has no direct Graph equivalent | 2 |
| OGM203 | Query-time synonym expansion has no Graph equivalent | 1 |
| OGM018 | BestBets(...) has no direct Graph equivalent | 1 |
&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;code-line&quot;&gt;That &quot;Top blocking patterns&quot; table is the slide you take to the planning meeting.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;Four output formats are supported:&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;console, markdown, html, json&lt;/span&gt;, and&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;sarif&lt;/span&gt;. SARIF is proper 2.1.0, so Azure DevOps and GitHub code scanning will render the findings inline on pull requests without any extra glue.&lt;/p&gt;
&lt;hr class=&quot;code-line&quot; /&gt;
&lt;h2 class=&quot;code-line&quot;&gt;Beyond the query chains&lt;/h2&gt;
&lt;p class=&quot;code-line&quot;&gt;Queries are the obvious target, but they&#39;re not the whole migration. The tool also covers three areas that tend to get missed during scoping:&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;&lt;strong&gt;Indexing conventions.&lt;/strong&gt;&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;client.Conventions.ForInstancesOf&amp;lt;T&amp;gt;().ShouldIndex(...).IncludeField(...)&lt;/span&gt; is C# configuration that has no in-code Graph equivalent, it becomes schema configuration in Graph instead. Easy to overlook, because it usually lives in an initialization module nobody has opened in two years.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;&lt;strong&gt;Find-backed&amp;nbsp;&lt;/strong&gt;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;IContentLoader&lt;/span&gt;&lt;strong&gt;&amp;nbsp;helpers.&lt;/strong&gt;&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;FindByContentType&amp;lt;T&amp;gt;(),&amp;nbsp;FindPagesWithCriteria&amp;lt;T&amp;gt;(),&amp;nbsp;GetChildrenWithFind&amp;lt;T&amp;gt;()&lt;/span&gt; - these don&#39;t look like search code at the call site. They look like content loading. Every one is a call site that needs to become an explicit Graph query.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;&lt;strong&gt;The static singleton.&lt;/strong&gt;&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;SearchClient.Instance&lt;/span&gt;&amp;nbsp;has no Graph SDK equivalent, and every usage is a place where you&#39;ll need to introduce proper dependency injection as part of the port.&lt;/p&gt;
&lt;hr class=&quot;code-line&quot; /&gt;
&lt;h2 class=&quot;code-line&quot;&gt;Installing and running it&lt;/h2&gt;
&lt;p class=&quot;code-line&quot;&gt;The tool targets .NET 10 and uses MSBuildWorkspace (falling back to a source-only heuristic scan for legacy, non-SDK-style CMS 11 projects) to analyze your solution, so you need a .NET SDK installed &amp;mdash; Visual Studio/Build Tools MSBuild is not required. It analyzes the code statically; it does not need credentials, an index, or a running Find instance, and it makes no network calls, whether your solution targets modern SDK-style CMS 12+ projects or legacy CMS 11 (.NET Framework, packages.config) projects.&lt;/p&gt;
&lt;h3 class=&quot;code-line&quot;&gt;From source&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;code-line language-powershell&quot;&gt;git clone &amp;lt;REPO&lt;span class=&quot;hljs-literal&quot;&gt;-URL&lt;/span&gt;&amp;gt;
&lt;span class=&quot;hljs-built_in&quot;&gt;cd&lt;/span&gt; OptiGraphMigrator
dotnet build OptiGraphMigrator.slnx
dotnet run &lt;span class=&quot;hljs-literal&quot;&gt;--project&lt;/span&gt; src\OptiGraphMigrator.Tool\OptiGraphMigrator.Tool.csproj &lt;span class=&quot;hljs-literal&quot;&gt;--&lt;/span&gt; scan &amp;lt;path&lt;span class=&quot;hljs-literal&quot;&gt;-to-your-solution&lt;/span&gt;&amp;gt; &lt;span class=&quot;hljs-literal&quot;&gt;--format&lt;/span&gt; markdown
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 class=&quot;code-line&quot;&gt;As a global tool&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;code-line language-powershell&quot;&gt;dotnet pack src\OptiGraphMigrator.Tool\OptiGraphMigrator.Tool.csproj &lt;span class=&quot;hljs-literal&quot;&gt;-c&lt;/span&gt; Release
dotnet tool install &lt;span class=&quot;hljs-literal&quot;&gt;--global&lt;/span&gt; &lt;span class=&quot;hljs-literal&quot;&gt;--add-source&lt;/span&gt; artifacts OptiGraphMigrator.Tool

optigraph&lt;span class=&quot;hljs-literal&quot;&gt;-migrate&lt;/span&gt; scan MySolution.sln &lt;span class=&quot;hljs-literal&quot;&gt;--format&lt;/span&gt; markdown &lt;span class=&quot;hljs-literal&quot;&gt;--output&lt;/span&gt; migration&lt;span class=&quot;hljs-literal&quot;&gt;-report&lt;/span&gt;.md
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 class=&quot;code-line&quot;&gt;Options&lt;/h3&gt;
&lt;table class=&quot;code-line&quot;&gt;
&lt;thead class=&quot;code-line&quot;&gt;
&lt;tr class=&quot;code-line&quot;&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody class=&quot;code-line&quot;&gt;
&lt;tr class=&quot;code-line&quot;&gt;
&lt;td&gt;&lt;code&gt;--format &amp;lt;console|sarif|html|json|markdown&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Report format. Defaults to&amp;nbsp;&lt;code&gt;console&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&quot;code-line&quot;&gt;
&lt;td&gt;&lt;code&gt;--output, -o &amp;lt;file&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Write to a file instead of stdout.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&quot;code-line&quot;&gt;
&lt;td&gt;&lt;code&gt;--rules &amp;lt;file&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A rule catalogue that overrides or extends the built-in mappings.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&quot;code-line&quot;&gt;
&lt;td&gt;&lt;code&gt;--severity-threshold &amp;lt;info|warning|error&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Minimum severity to include in the report.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&quot;code-line&quot;&gt;
&lt;td&gt;&lt;code&gt;--fail-on &amp;lt;info|warning|error&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Minimum severity that produces a non-zero exit code.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 class=&quot;code-line&quot;&gt;In CI&lt;/h3&gt;
&lt;p class=&quot;code-line&quot;&gt;Exit codes are designed to be gated on:&lt;/p&gt;
&lt;ul class=&quot;code-line&quot;&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;0&lt;/strong&gt; - scan completed, nothing met the &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;--fail-on&amp;nbsp;&lt;/span&gt;threshold.&lt;/li&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;1&lt;/strong&gt; - scan completed, findings met the threshold.&lt;/li&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;2&lt;/strong&gt; - the scan could not run (bad path, workspace load failure, unexpected error).&lt;/li&gt;
&lt;/ul&gt;
&lt;p class=&quot;code-line&quot;&gt;The distinction between 1 and 2 matters.&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;2&lt;/span&gt;&amp;nbsp;is an infrastructure problem and should always break the build;&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;1&lt;/span&gt;&amp;nbsp;is a finding and you get to decide how strict to be. A useful pattern during an active migration is to run with&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;--fail-on error&lt;/span&gt;&amp;nbsp;so the build breaks only when someone introduces&amp;nbsp;&lt;em&gt;new&lt;/em&gt;&amp;nbsp;Blocked-tier Find usage into a codebase you&#39;re trying to move off it.&lt;/p&gt;
&lt;hr class=&quot;code-line&quot; /&gt;
&lt;h2 class=&quot;code-line&quot;&gt;As an analyzer, while you&#39;re actually porting&lt;/h2&gt;
&lt;p class=&quot;code-line&quot;&gt;The CLI is for scoping. Once the migration is underway, there&#39;s a second mode: the analyzer and code fix provider pack as a normal analyzer package, so you get the same diagnostics as squiggles in Visual Studio or Rider, on the file you&#39;re editing, with the suggested Graph equivalent right there in the tooltip.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;The mappings marked&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;isAutoFixable&lt;/span&gt;&amp;nbsp;in the rule catalogue also ship a code fix, so the genuinely mechanical ones can be applied with Ctrl+&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;.&lt;/span&gt;&amp;nbsp;rather than by hand.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;Scope with the CLI, port with the analyzer.&lt;/p&gt;
&lt;hr class=&quot;code-line&quot; /&gt;
&lt;h2 class=&quot;code-line&quot;&gt;Teaching it your codebase&lt;/h2&gt;
&lt;p class=&quot;code-line&quot;&gt;There are 47 rules today: 34 declarative mappings plus 12 hand-written rules for patterns that need real semantic inspection (checking what&#39;s inside a&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;Filter()&lt;/span&gt;&amp;nbsp;lambda, for instance).&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;The declarative ones live in a JSON catalogue, and you can override or extend it without touching the code, point &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;--rules&lt;/span&gt;&amp;nbsp;at your own file, or drop an&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;optigraph.rules.json&lt;/span&gt;&amp;nbsp;next to your solution and it&#39;ll be discovered by walking up the directory tree, the same way&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;.editorconfig&lt;/span&gt;&amp;nbsp;works. A rule looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;code-line language-json&quot;&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;hljs-attr&quot;&gt;&quot;id&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;OGM001&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;hljs-attr&quot;&gt;&quot;title&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;Filter(...) maps to Graph where(...)&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;hljs-attr&quot;&gt;&quot;severity&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;warning&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;hljs-attr&quot;&gt;&quot;category&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;filtering&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;hljs-attr&quot;&gt;&quot;translatability&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;exact&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;hljs-attr&quot;&gt;&quot;findSymbolPattern&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;&quot;containingType&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;EPiServer.Find.Api.Querying.FilterExtensions&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;&quot;methodName&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;Filter&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;&quot;minArguments&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;&quot;matchDerivedTypes&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-literal&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;true&lt;/span&gt;&lt;/span&gt;
  &lt;span class=&quot;hljs-punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;hljs-attr&quot;&gt;&quot;graphEquivalent&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;.Where(x =&amp;gt; &amp;lt;translated predicate&amp;gt;)&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;hljs-attr&quot;&gt;&quot;graphQlSnippetTemplate&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;where: {{ {0} }}&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;hljs-attr&quot;&gt;&quot;caveats&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-punctuation&quot;&gt;[&lt;/span&gt;
    &lt;span class=&quot;hljs-string&quot;&gt;&quot;Find&#39;s Filter() predicates are evaluated server-side against the index; confirm the equivalent Graph &#39;where&#39; clause targets the matching indexed field name.&quot;&lt;/span&gt;
  &lt;span class=&quot;hljs-punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;hljs-attr&quot;&gt;&quot;isAutoFixable&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-literal&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;true&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;hljs-punctuation&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p class=&quot;code-line&quot;&gt;Rules with a matching&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;id&lt;/span&gt; replace the built-in ones; new ids are appended. So if your solution wraps Find in your own extension methods, and most mature solutions do, you can teach the tool about your wrappers in a few lines of JSON and get them classified alongside everything else. That&#39;s the difference between a report that covers 60% of your search code and one that covers all of it.&lt;/p&gt;
&lt;hr class=&quot;code-line&quot; /&gt;
&lt;h2 class=&quot;code-line&quot;&gt;What it won&#39;t do&lt;/h2&gt;
&lt;p class=&quot;code-line&quot;&gt;Worth being direct about the limits:&lt;/p&gt;
&lt;ul class=&quot;code-line&quot;&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;It does not rewrite your queries for you.&lt;/strong&gt;&amp;nbsp;A handful of Exact mappings have code fixes. The rest is a map, not a migration. Anyone promising automated Find &amp;rarr; Graph translation for anything beyond the trivial cases is overselling.&lt;/li&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;Caveat findings genuinely need a human.&lt;/strong&gt;&amp;nbsp;The tool tells you the semantics differ and why; it cannot tell you whether your users will notice.&lt;/li&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;Unrecognised API surface is reported, not hidden.&lt;/strong&gt; If a chain can&#39;t be fully resolved, an overload the catalogue doesn&#39;t know, or a compilation error, you get an &lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;OGM901&lt;/span&gt;&amp;nbsp;info-level finding saying so, rather than a silent gap in the report. If you see a lot of those, that&#39;s a signal to extend the catalogue.&lt;/li&gt;
&lt;li class=&quot;code-line&quot;&gt;&lt;strong&gt;It needs your solution to build.&lt;/strong&gt;&amp;nbsp;Roslyn needs a working compilation for symbol resolution.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr class=&quot;code-line&quot; /&gt;
&lt;h2 class=&quot;code-line&quot;&gt;Try it on your worst solution&lt;/h2&gt;
&lt;p class=&quot;code-line&quot;&gt;If you&#39;re weighing up a Find &amp;rarr; Graph migration, the most valuable thing you can do this week is run this against your largest legacy solution and look at nothing but the Blocked list. It takes about five minutes and it will tell you whether you&#39;re looking at a port or a redesign.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;I&#39;d particularly like to hear about two things: Find API surface that produces&amp;nbsp;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;OGM901&lt;/span&gt; unresolved findings (that&#39;s a gap in the catalogue and easy to fix), and any mapping you think is classified wrong, especially anything I&#39;ve marked Exact that bit you in practice. The translatability calls are judgement, and they get better with more codebases behind them.&lt;/p&gt;
&lt;p class=&quot;code-line&quot;&gt;Source, rules documentation and issues: &lt;a href=&quot;https://github.com/adayinthelifeofapro/OptiGraphMigrator/tree/main/OptiGraphMigrator&quot;&gt;OptiGraphMigrator/OptiGraphMigrator at main &amp;middot; adayinthelifeofapro/OptiGraphMigrator&lt;/a&gt;&lt;br /&gt;&lt;br /&gt;&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;div class=&quot;author__image col-sm-5&quot;&gt;&lt;img class=&quot;clickable&quot; title=&quot;Click image to zoom&quot; src=&quot;/link/e6418e9a3a8743e69f1390b78628d8ef.aspx&quot; alt=&quot;&quot; width=&quot;288&quot; height=&quot;270&quot; /&gt;&lt;/div&gt;
&lt;div class=&quot;author__content col-sm-7 keep-margin-bottom&quot;&gt;
&lt;h2&gt;Graham Carr, Technical Architect&lt;/h2&gt;
&lt;div class=&quot;rich-text aos-init aos-animate&quot;&gt;
&lt;p&gt;I am an experienced Technical Architect with over 30 years&amp;rsquo; experience in a wide range of products and technologies. I have helped companies deliver their digital vision from concept all the way through to delivery. I have a particular passion for DXPs (Digital Experience Platforms) and am a certified developer for Optimizely as well as a Platinum OMVP.&lt;/p&gt;
&lt;p&gt;You can also follow me on&amp;nbsp;&lt;a href=&quot;https://adayinthelife.pro/&quot;&gt;https://adayinthelife.pro&lt;/a&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;</description>            <guid>https://world.optimizely.com/blogs/allthingsopti/dates/2026/9/a-day-in-the-life-of-an-optimizely-omvp-scoping-a-search--navigation---optimizely-graph-migration-with-roslyn/</guid>            <pubDate>Sat, 12 Sep 2026 19:05:13 GMT</pubDate>           <category>Blog post</category></item><item> <title>Machines Are Reading Your Content Model</title>            <link>https://world.optimizely.com/blogs/vipin-banka--learnings--insights/dates/2026/9/machines-are-reading-your-content-model/</link>            <description>&lt;p&gt;&lt;em&gt;How Optimizely CMS (SaaS) separates presentation intent from content &amp;mdash; and why that now matters for AI discoverability.&lt;/em&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Start with a requirement that looks completely reasonable&lt;/h2&gt;
&lt;p&gt;A client asks for a Heading content type. The requirements are modest:&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;A field for the heading text&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;A dropdown so the author can choose H1 through H6&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;A toggle for an underline&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;A colour choice for that underline &amp;mdash; red or white&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Nothing here is unusual. Most of us have built something close to it. It takes an afternoon, the client is happy, and the content type ships.&lt;/p&gt;
&lt;p&gt;Before going further, the sanity checks are worth doing &amp;mdash; because sometimes the answer is simply &quot;this is fine&quot;:&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;Is this a one-off page, or a pattern that will repeat across the site?&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;If the brand colour changes next year, how many content items need editing?&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For a small site updated twice a year: build the dropdown and move on. Not every project needs an abstraction layer.&lt;/p&gt;
&lt;p&gt;But on an enterprise build with hundreds of pages and a multi-year lifespan, those questions start to sting. And there is a fourth one that has only recently become interesting: &lt;br /&gt;&lt;strong&gt;what does a machine see when it reads this page?&lt;/strong&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The actual problem: a vocabulary mismatch&lt;/h2&gt;
&lt;p&gt;A headless CMS is designed to store meaning. Authors think and speak in appearance &amp;mdash; bigger, bolder, red, underlined. That is not a failure on their part. It is the only vocabulary any CMS has ever offered them. Text field, dropdown, colour picker: the interface quietly teaches everyone that appearance is a thing you store.&lt;/p&gt;
&lt;p&gt;So when an author asks for a style option, they are very often expressing a &lt;strong&gt;semantic fact in visual language&lt;/strong&gt;. When we build exactly what was asked for, we store the visual consequence and throw the semantic fact away.&lt;/p&gt;
&lt;p&gt;Look again at those three requirements. They are not three styling options &amp;mdash; they belong to three different layers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;H1 through H6&quot;&lt;/strong&gt; &amp;mdash; when an author picks H3, they are rarely thinking about document outline. They are thinking &lt;em&gt;&quot;I want text roughly this big.&quot;&lt;/em&gt; A heading level is a statement about document structure. Size is a statement about visual emphasis. The author was asking for the second and we gave them control over the first. That is how pages end up with three H1s, or an outline that jumps from H2 straight to H5.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;Red or white&quot;&lt;/strong&gt; &amp;mdash; ask why they want white. In my experience the answer is always: &lt;em&gt;&quot;because on the dark sections the red doesn&#39;t work.&quot;&lt;/em&gt; The author is not choosing a colour. They are restating the background of the section they are sitting in, by hand, on every heading. The section already knows whether it is light or dark. Let the heading inherit from that.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;Underline on or off&quot;&lt;/strong&gt; &amp;mdash; this one survives. It is genuinely decorative and the author&#39;s judgement is the right input. Although even here, a name like &lt;strong&gt;accent&lt;/strong&gt; travels better than &lt;strong&gt;underline&lt;/strong&gt;, because it describes the role rather than the rendering.&lt;/p&gt;
&lt;p&gt;One out of three requirements was really a style choice.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;A three-layer way of thinking about it&lt;/h2&gt;
&lt;p&gt;Most teams think in two layers &amp;mdash; content and style &amp;mdash; and that is why the boundary collapses. There is a third layer in the middle that tends to get forgotten.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; border: 1px solid rgb(0, 0, 0);&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;padding: 10px; text-align: left; background-color: rgb(236, 240, 241); vertical-align: top; border: 1px solid rgb(0, 0, 0);&quot;&gt;
&lt;p&gt;&lt;strong&gt;Layer&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;padding: 10px; text-align: left; background-color: rgb(236, 240, 241); vertical-align: top; border: 1px solid rgb(0, 0, 0);&quot;&gt;
&lt;p&gt;&lt;strong&gt;The question it answers&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;padding: 10px; text-align: left; background-color: rgb(236, 240, 241); vertical-align: top; border: 1px solid rgb(0, 0, 0);&quot;&gt;
&lt;p&gt;&lt;strong&gt;Channel-neutral?&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border: 1px solid rgb(0, 0, 0);&quot;&gt;
&lt;p&gt;Content&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border: 1px solid rgb(0, 0, 0);&quot;&gt;
&lt;p&gt;What is true?&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border: 1px solid rgb(0, 0, 0);&quot;&gt;
&lt;p&gt;Yes&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border: 1px solid rgb(0, 0, 0);&quot;&gt;
&lt;p&gt;Presentation intent&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border: 1px solid rgb(0, 0, 0);&quot;&gt;
&lt;p&gt;What does the author mean? (emphasis, prominence)&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border: 1px solid rgb(0, 0, 0);&quot;&gt;
&lt;p&gt;Yes &amp;mdash; semantic, not visual&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border: 1px solid rgb(0, 0, 0);&quot;&gt;
&lt;p&gt;Presentation implementation&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border: 1px solid rgb(0, 0, 0);&quot;&gt;
&lt;p&gt;What does it look like here?&lt;/p&gt;
&lt;/td&gt;
&lt;td style=&quot;padding: 10px; text-align: left; vertical-align: top; border: 1px solid rgb(0, 0, 0);&quot;&gt;
&lt;p&gt;No&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;br /&gt;The two failure modes are collapsing the middle layer into either neighbour. Push it into content and you get colour values in your content types. Push it into implementation and authors lose all control &amp;mdash; and start requesting new content types instead.&lt;/p&gt;
&lt;p&gt;A practical test: &lt;strong&gt;would this value still mean something to a channel that has no screen?&lt;/strong&gt; &quot;This is the primary call to action&quot; survives. &quot;This is #0A2540 with 24 pixels of padding&quot; does not.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Where Optimizely CMS (SaaS) fits&lt;/h2&gt;
&lt;p&gt;This is the part I find genuinely well designed.&lt;/p&gt;
&lt;p&gt;In CMS (SaaS), display templates are defined &lt;strong&gt;separately from the content type&#39;s property schema&lt;/strong&gt;. You define templates that carry settings, and Optimizely Graph indexes the selected template and settings alongside the content and delivers both to the front end. Each node in the composition model can carry a &lt;strong&gt;displayTemplateKey&lt;/strong&gt; and a set of &lt;strong&gt;displaySettings&lt;/strong&gt; &amp;mdash; key/value pairs the front end interprets however it likes. See &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs/configure-visual-builder&quot;&gt;Configure Visual Builder&lt;/a&gt; and &lt;a href=&quot;https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs/work-with-styles&quot;&gt;Manage styles&lt;/a&gt; for the details.&lt;/p&gt;
&lt;p&gt;That separation is exactly the middle layer the three-layer model is asking for. The platform gives you a dedicated channel for presentation intent that never touches your content schema.&lt;/p&gt;
&lt;p&gt;What the platform cannot do is police the vocabulary you put in it. A display setting choice is a string &amp;mdash; it will happily store &quot;primary&quot; or &quot;#E4002B&quot; and return either one. Which leads to the single highest-leverage habit in this whole discussion, and it costs nothing: &lt;strong&gt;the stored key and the label the author sees are separate values.&lt;/strong&gt; Let the author see &quot;Red&quot; and &quot;White&quot;. Store &quot;primary&quot; and &quot;inverse&quot;. The client gets precisely the choice they asked for. You keep the abstraction. When red becomes crimson, you change one token.&lt;/p&gt;
&lt;p&gt;To make the difference concrete, here is the shape of both approaches. These are conceptual illustrations &amp;mdash; not production schemas &amp;mdash; but the pattern is the one worth internalising.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The approach to avoid&lt;/strong&gt; &amp;mdash; presentation stored directly as content type properties:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-json&quot;&gt;// Illustrative &amp;mdash; shows the pattern to avoid
{
  &quot;key&quot;: &quot;HeadingElement&quot;,
  &quot;properties&quot;: {
    &quot;text&quot;:            { &quot;type&quot;: &quot;string&quot; },
    &quot;headingTag&quot;:      { &quot;type&quot;: &quot;string&quot; },
    &quot;underlineColour&quot;: { &quot;type&quot;: &quot;string&quot; }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every channel that is not a web browser now receives a heading tag and a colour hex value it has no use for.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The cleaner approach&lt;/strong&gt; &amp;mdash; content type holds only content, display template carries intent:&lt;/p&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-json&quot;&gt;// Content type &amp;mdash; only what is semantically true
{
  &quot;key&quot;: &quot;HeadingElement&quot;,
  &quot;properties&quot;: {
    &quot;text&quot;:          { &quot;type&quot;: &quot;string&quot; },
    &quot;levelOverride&quot;: { &quot;type&quot;: &quot;string&quot; }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;code-block&quot;&gt;&lt;code class=&quot;language-json&quot;&gt;// Display template &amp;mdash; semantic intent, not visual values
// The author sees &quot;Red&quot; / &quot;White&quot;. Graph stores &quot;primary&quot; / &quot;inverse&quot;.
{
  &quot;key&quot;: &quot;HeadingStyle&quot;,
  &quot;contentType&quot;: &quot;HeadingElement&quot;,
  &quot;settings&quot;: {
    &quot;size&quot;:         { &quot;choices&quot;: { &quot;large&quot;:   { &quot;displayName&quot;: &quot;Large&quot;      },
                                   &quot;medium&quot;:  { &quot;displayName&quot;: &quot;Medium&quot;     } } },
    &quot;accent&quot;:       { &quot;choices&quot;: { &quot;none&quot;:    { &quot;displayName&quot;: &quot;None&quot;       },
                                   &quot;rule&quot;:    { &quot;displayName&quot;: &quot;Underlined&quot; } } },
    &quot;accentColour&quot;: { &quot;choices&quot;: { &quot;primary&quot;: { &quot;displayName&quot;: &quot;Red&quot;        },
                                   &quot;inverse&quot;: { &quot;displayName&quot;: &quot;White&quot;      } } }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The author gets the control they asked for. Graph delivers clean semantic keys. The front end resolves the token. The heading arrives usable on every channel.&lt;/p&gt;
&lt;p&gt;Conceptually, the front end then owns a single mapping from semantic intent to channel-specific implementation &amp;mdash; the only place that knows what &quot;prominent&quot; actually looks like on this channel. That is the pattern worth establishing, whatever your stack.&lt;/p&gt;
&lt;p&gt;If you would rather not hand-write display template payloads, Opal Chat exposes tools for listing, retrieving, creating and updating them conversationally &amp;mdash; which also makes auditing straightforward. Asking for a list of every display template and its choice keys is a quick way to spot hex values that have crept in.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The part that has changed recently&lt;/h2&gt;
&lt;p&gt;Until recently, the argument for keeping presentation out of content ran into a simple objection: &lt;em&gt;&quot;we are a web-only business.&quot;&lt;/em&gt; And the counter-argument &amp;mdash; hypothetical future channels &amp;mdash; never beats a delivery date.&lt;/p&gt;
&lt;p&gt;That objection is weaker now, because there is a channel already consuming this content that nobody commissioned.&lt;/p&gt;
&lt;p&gt;When an AI system reads a page &amp;mdash; for a summary, an answer, a citation &amp;mdash; it works primarily with text and structure. A red underline communicates nothing to it. Heading hierarchy, on the other hand, is one of the main signals for understanding how a document is organised and where one idea ends and the next begins. That H1-to-H6 dropdown, built in good faith as a small author convenience, is quietly working against discoverability in a channel the client does care about.&lt;/p&gt;
&lt;p&gt;I want to be careful not to overstate this. Crawlers vary in sophistication, and large language models are genuinely capable of extracting meaning from messy documents. This is not a claim that a disorganised heading structure makes you invisible. It is a claim about direction: the more meaning that lives in structure rather than appearance, the more of it survives into a system that cannot see your stylesheet. The general guidance for AI-era discoverability &amp;mdash; crawlability, clear structure, structured data &amp;mdash; is the same advice good content modelling has always produced as a by-product.&lt;/p&gt;
&lt;p&gt;Look at how &lt;a href=&quot;https://support.optimizely.com/hc/en-us/articles/23727985454861-Optimizely-Opal-and-AI-features&quot;&gt;Optimizely&#39;s own Opal agents&lt;/a&gt; interact with this, because their operational requirements illustrate the point clearly.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://support.optimizely.com/hc/en-us/articles/42859993692429-GEO-Schema-Optimization-agent-CMS-SaaS&quot;&gt;GEO Schema Optimization agent&lt;/a&gt; analyzes page content and preview URLs to generate JSON-LD schema markup, which it writes directly back into a schema property in CMS. While the agent can parse rendered HTML, the output needs a structured home. If your content model provides explicit metadata fields and a designated schema property, the workflow is automated and durable. When metadata has to be reverse-engineered from visual text styles, the process becomes lossy.&lt;/p&gt;
&lt;p&gt;Similarly, the &lt;a href=&quot;https://support.optimizely.com/hc/en-us/articles/42925645124109-SEO-Metadata-Implementation-agent&quot;&gt;SEO Metadata Implementation agent&lt;/a&gt; evaluates SEO properties and updates metadata fields in CMS (SaaS). For an agent to push automated improvements, the destination fields must exist in the content model. If title or description semantics are treated merely as visual heading blocks rather than typed metadata, automated governance has nowhere to land its changes.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://support.optimizely.com/hc/en-us/articles/39809792859277-Content-Model-Creation-agent-CMS-SaaS&quot;&gt;Content Model Creation agent&lt;/a&gt; takes a URL or image and suggests a content model, specifically comparing its recommendations against your existing content types to classify them as &quot;New&quot; or &quot;Modified&quot;. This is where content modeling habits compound: the agent references what is already registered in your CMS instance. If your existing models are already cleanly separated into content fields and display templates, new recommendations naturally align with that standard. If existing types mix styling flags into content properties, extending them propagates that debt.&lt;/p&gt;
&lt;p&gt;AI agents are effective at analyzing and automating workflows across your CMS, but their leverage depends on the clarity of the underlying schema. Clean structure gives automation clean inputs and reliable destinations.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Honest limitations&lt;/h2&gt;
&lt;p&gt;This model is not complete, and it is worth saying so before someone else does.&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Reused content across surfaces&lt;/strong&gt; &amp;mdash; &quot;the parent owns surface, the child owns emphasis&quot; works well in hierarchical composition. When a block genuinely appears in both a light section and a dark one, inheritance is not enough, and I do not think there is one settled answer yet.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Vocabulary discipline&lt;/strong&gt; &amp;mdash; a semantic layer is only as stable as the names in it. Someone has to own that vocabulary across teams and years. A hex value is at least unambiguous.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Scale&lt;/strong&gt; &amp;mdash; most of this assumes an enterprise build with a long life and multiple teams. For smaller projects the machinery can outweigh the problem.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;The question that does most of the work&lt;/h2&gt;
&lt;p&gt;If there is one thing worth taking from this, it is a question to ask in the next content modelling session, whenever someone requests a visual option:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&quot;Why do you want to be able to choose that?&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The answer is usually a semantic fact wearing visual clothing.&lt;/p&gt;
&lt;ul class=&quot;tight&quot;&gt;
&lt;li&gt;
&lt;p&gt;&quot;H1 through H6&quot; &amp;rarr; &lt;em&gt;I want it this big&lt;/em&gt; &amp;rarr; that is size&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&quot;Red or white&quot; &amp;rarr; &lt;em&gt;the background is dark&lt;/em&gt; &amp;rarr; that is theme&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&quot;Underline it&quot; &amp;rarr; sometimes it really is just decoration, and that is fine&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One of those three was a genuine style choice. Finding out which is the whole exercise.&lt;/p&gt;
&lt;p&gt;Your content model is a contract. You have always been signing it on behalf of your authors and your developers. The new part is that you are now also signing it on behalf of every machine that helps decide whether anyone finds you.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/vipin-banka--learnings--insights/dates/2026/9/machines-are-reading-your-content-model/</guid>            <pubDate>Sat, 12 Sep 2026 17:46:40 GMT</pubDate>           <category>Blog post</category></item><item> <title>Content Transfer addon comes to CMS 13</title>            <link>https://world.optimizely.com/blogs/matt-pallatt-is-not-a-developer/dates/2026/9/content-transfer-comes-to-cms-132/</link>            <description>&lt;p&gt;Whilst we&#39;re waiting the release of OCP UI apps and with it &lt;span style=&quot;text-decoration: underline; color: rgb(35, 111, 161);&quot;&gt;&lt;a style=&quot;color: rgb(35, 111, 161); text-decoration: underline;&quot; href=&quot;/link/b2da5c49916b4aeeb35b293a1cbf1312.aspx&quot;&gt;Content Transfer for SaaS&lt;/a&gt;&lt;/span&gt;, I thought I&#39;d pull the features and functionality from the &lt;span style=&quot;text-decoration: underline; color: rgb(35, 111, 161);&quot;&gt;&lt;a style=&quot;color: rgb(35, 111, 161); text-decoration: underline;&quot; href=&quot;/link/9ad65182172c47689f5e21a8f01f781b.aspx&quot;&gt;CMS 12 Content Transfer add-on&lt;/a&gt;&lt;/span&gt; across to CMS 13, and so I give you &lt;span style=&quot;color: rgb(35, 111, 161);&quot;&gt;&lt;a style=&quot;color: rgb(35, 111, 161);&quot; href=&quot;https://nuget.optimizely.com/packages/mp.dxpcontenttransfer.cms13/&quot;&gt;&lt;span style=&quot;text-decoration: underline;&quot;&gt;Content Transfer for CMS 13&lt;/span&gt;&lt;/a&gt;&lt;/span&gt;.&lt;/p&gt;
&lt;p&gt;Unlike with CMS 12 there&#39;s no need for the Content Management or Content Delivery API as all the work is done with the REST API that comes out of the box with CMS 13.&lt;/p&gt;
&lt;p&gt;Featurewise, it works exactly the same - it allows you to push published content (pages, blocks, images, references, documents, videos, etc.) between different DXP (or any CMS 13 instance for that matter) environments - though in a preview to what the SaaS tool is bringing, retargeting has been added, allowing you to now move content to anywhere in the target CMS!&lt;/p&gt;
&lt;p&gt;So, choose what you want to move...&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/3b74ceef7f29422b955b5af6d530908e.aspx?1788619784931&quot; alt=&quot;Content Transfer Interface showing options for moving content&quot; width=&quot;400&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Then watch it and associated items wing their way to the environment of your choosing...&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/7392b7f4ff1840ea8169f5953f7c2768.aspx?1788619652060&quot; alt=&quot;Content Transfer interface showing file transfers&quot; width=&quot;400&quot; height=&quot;412&quot; /&gt;&lt;/p&gt;
&lt;p&gt;As always, feedback is appreciated.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/matt-pallatt-is-not-a-developer/dates/2026/9/content-transfer-comes-to-cms-132/</guid>            <pubDate>Fri, 11 Sep 2026 12:23:12 GMT</pubDate>           <category>Blog post</category></item><item> <title>Meet the latest OMVPs - H2 2026 Cohort!</title>            <link>https://world.optimizely.com/blogs/raiyan-binta-abdullah/dates/2026/9/meet-the-latest-omvps---h2-2026-cohort/</link>            <description>&lt;div&gt;
&lt;p&gt;Hi, I&amp;rsquo;m Raiyan Binte Abdullah, and I&amp;rsquo;m really excited to introduce myself as the newest member for managing the&amp;nbsp;&lt;strong&gt;OMVP &lt;/strong&gt;program and the digital community.&lt;/p&gt;
&lt;p&gt;As this is my first time stepping into this role, I&amp;rsquo;m looking forward to getting to know more of you, hearing your ideas, and being someone you can reach out to whenever you need support. Whether you have questions about the OMVP program, want to share a community contribution, have an idea for content or events, or simply aren&amp;rsquo;t sure who to reach out to &amp;mdash; I&amp;rsquo;m here to help connect you with the right person or resource.&lt;/p&gt;
&lt;p&gt;Please don&amp;rsquo;t hesitate to reach out to me directly. I&amp;rsquo;d genuinely love to hear from you, and I&amp;rsquo;m looking forward to working with and getting to know the OMVP community better!&lt;/p&gt;
&lt;p&gt;&#128233;&amp;nbsp;&lt;strong&gt;Reach out:&lt;/strong&gt;&amp;nbsp;&lt;a href=&quot;mailto:raiyanbinta.abdullah@optimizely.com&quot;&gt;raiyanbinta.abdullah@optimizely.com&lt;/a&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;Some people don&#39;t just use Optimizely &amp;mdash; they champion it. They stay late to answer a stranger&#39;s forum question, turn a hard-won project lesson into a blog post so the next team doesn&#39;t hit the same wall, and step on stage to share what they&#39;ve learned. Every year, the Optimizely Most Valuable Professional (OMVP) program exists to celebrate exactly these people.&lt;/p&gt;
&lt;p&gt;OMVPs are the practitioners who build brilliant things on the platform &lt;em&gt;and&lt;/em&gt; give their knowledge away freely &amp;mdash; mentoring peers, sparking discussions, and lifting the whole community as they grow. They come from every corner of the ecosystem: solution architects and developers, strategists and digital leaders, each with their own story and their own way of moving Optimizely forward. What they share is generosity: through blogs, talks, technical contributions, and real-world work, they make everyone around them better.&lt;/p&gt;
&lt;p&gt;Today, we&#39;re proud to introduce the newest cohort &amp;mdash; the 2026 H2 Optimizely MVPs. Every name here represents deep expertise, hard-earned experience, and a genuine commitment to sharing it.&lt;/p&gt;
&lt;p&gt;So let&#39;s meet them. &#128079;&lt;/p&gt;
&lt;div class=&quot;MsoNormal&quot; align=&quot;center&quot;&gt;&lt;hr /&gt;&lt;/div&gt;
&lt;h2&gt;YiLun Miao&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://images3.cmp.optimizely.com/Zz0wYzk4YjY1OGFkMDYxMWYxYmRjMDk2Y2YyYzcyODk5ZQ==?checkExpiry=false&quot; alt=&quot;Yilun OMVP Profile picture.png&quot; width=&quot;212&quot; height=&quot;265&quot; /&gt;&lt;/p&gt;
&lt;p&gt;YiLun Miao has a rare talent: making emerging technology feel less like a leap and more like a natural next step. As Country Manager for Niteco ANZ, he&#39;s become one of the most recognizable champions of Optimizely and its AI capabilities across the ANZ and APJ markets, famous for explaining Opal through everyday moments like learning to ski or eating a Dutch herring sandwich, analogies now quoted on stage by Optimizely&#39;s own leadership.&lt;/p&gt;
&lt;p&gt;Behind the storytelling is real momentum: enterprise POCs, region-defining workshops, and a knack for connecting customers, partners, and technology so digital transformation actually lands. Generous with what he knows, YiLun shares practical insight freely with the wider Optimizely community, and this cycle, he earned the program&#39;s highest contribution score.&lt;/p&gt;
&lt;div class=&quot;MsoNormal&quot; align=&quot;center&quot;&gt;&lt;hr /&gt;&lt;/div&gt;
&lt;h2&gt;Igor Safonov&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://images3.cmp.optimizely.com/Zz03NDUwZmFkMGFkMDYxMWYxOGFmYjk2Y2YyYzcyODk5ZQ==?checkExpiry=false&quot; alt=&quot;igor-cropped-white-bg.png&quot; width=&quot;234&quot; height=&quot;213&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Igor Sufonov is a Solutions Architect at Niteco with more than 15 years of experience working with digital platforms and enterprise technology. He works across the Optimizely platform, with a particular focus on CMS, content operations, solution architecture, integrations, experimentation, and AI.&lt;/p&gt;
&lt;p&gt;Igor enjoys sharing practical experience from real projects through articles, discussions, and community events. Recently, he has been particularly interested in Optimizely Opal and exploring how AI can be applied to real-world content, marketing, and solution delivery challenges.&lt;/p&gt;
&lt;div class=&quot;MsoNormal&quot; align=&quot;center&quot;&gt;&lt;hr /&gt;&lt;/div&gt;
&lt;h2&gt;Vipin Banka&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://images1.cmp.optimizely.com/Zz05NDgzNmEyMmFkMDYxMWYxOGI4MTQ2NWU2NjFkNjY1Yw==?checkExpiry=false&quot; alt=&quot;vipin-banka.jpeg&quot; width=&quot;239&quot; height=&quot;239&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Vipin Banka is a technology leader, architect, and mentor with a passion for digital experience platforms, composable architecture, integrations, and AI-enabled engineering. He enjoys helping organizations navigate complex technology decisions and designing scalable solutions that balance business needs with long-term sustainability.&lt;/p&gt;
&lt;p&gt;As an active member of the Optimizely community, Vipin shares implementation learnings, architecture insights, and best practices through Optimizely World articles and community engagement. Beyond technology delivery, he is particularly interested in how AI and emerging technologies can enhance engineering productivity, solution design, and customer outcomes.&lt;/p&gt;
&lt;div class=&quot;MsoNormal&quot; align=&quot;center&quot;&gt;&lt;hr /&gt;&lt;/div&gt;
&lt;h2&gt;Piotr Nowak&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://images1.cmp.optimizely.com/Zz0xZWM2N2M0Y2FkMDcxMWYxOGI4MTQ2NWU2NjFkNjY1Yw==?checkExpiry=false&quot; alt=&quot;1765714355425.png&quot; width=&quot;234&quot; height=&quot;234&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Piotr Nowak is a Senior .NET Developer and Optimizely SME at ROCKWOOL Group, with nearly seven years on the platform spanning architecture, development, cloud migration, and DevOps automation across more than 40 global markets. He architects and builds on Optimizely CMS (PaaS and SaaS), Commerce, and Headless CMS with GraphQL.&lt;/p&gt;
&lt;p&gt;Piotr plans and executes complex migrations &amp;mdash; monolith to headless with Next.js, and Windows to Optimizely DXP cloud-native Linux &amp;mdash; on Azure infrastructure built as Infrastructure as Code via Azure DevOps and the Optimizely Deployment API. He also runs internal workshops on DXP migration, headless architecture, and best practices, and earned all eight Optimizely certifications in October 2025.&lt;/p&gt;
&lt;div class=&quot;MsoNormal&quot; align=&quot;center&quot;&gt;&lt;hr /&gt;&lt;/div&gt;
&lt;h2&gt;Jill George&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://images1.cmp.optimizely.com/Zz1jMTc0NzMxOGFkMDcxMWYxOGI4MTQ2NWU2NjFkNjY1Yw==?checkExpiry=false&quot; alt=&quot;Jill Profile.jpg&quot; width=&quot;223&quot; height=&quot;223&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Jill George is Senior Sales and Client Director at Niteco, where she works at the point where Optimizely&#39;s product direction meets how UK enterprises actually buy. She&#39;s built her platform knowledge deliberately rather than by default, holding multiple accreditations across the Optimizely ecosystem, and regularly shares insight into what each new release actually means for the businesses using it, not just what&#39;s new, but what it changes.&lt;/p&gt;
&lt;p&gt;Jill is passionate about closing the gap between what Optimizely can do and how well UK enterprises currently understand it, and is a passionate, visible voice for the platform.&lt;/p&gt;
&lt;div class=&quot;MsoNormal&quot; align=&quot;center&quot;&gt;&lt;hr /&gt;&lt;/div&gt;
&lt;h2&gt;Tom Denbigh&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://images3.cmp.optimizely.com/Zz1lMDE1NjdjOGFkMDcxMWYxYmIyYTUyZjZlMWM2ZjY2Mg==?checkExpiry=false&quot; alt=&quot;Tom Netcel.jpg&quot; width=&quot;238&quot; height=&quot;238&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Tom Denbigh is a Commercial Director at Netcel. He has spent the last decade on a simple conviction: that the best digital strategies start with people, not platforms. He helps organisations keep users at the heart of every objective &amp;mdash; and makes sure the technology follows, rather than leads.&lt;/p&gt;
&lt;p&gt;With deep expertise across platform strategy, personalisation, and user experience, Tom guides companies in getting real, measurable value from their Optimizely and marketing-technology investments. But his contribution reaches well beyond client work: through Netcel&#39;s webinar programme, his Opticon panels, and the Future Ready Forum he founded for senior digital leaders, he&#39;s created spaces for the whole community to learn from one another.&lt;/p&gt;
&lt;div class=&quot;MsoNormal&quot; align=&quot;center&quot;&gt;&lt;hr /&gt;&lt;/div&gt;
&lt;h2&gt;Kiran Patil&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://images3.cmp.optimizely.com/Zz00MzcwZGY4MmFkMDgxMWYxYmRjMDk2Y2YyYzcyODk5ZQ==?checkExpiry=false&quot; alt=&quot;KP-Headshot-vF.png&quot; width=&quot;242&quot; height=&quot;220&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Kiran Patil is a Group Technology Director at Horizontal, where he leads technical strategy and delivery across digital experience platform engagements. With 19 years in the industry building premium digital experiences for Fortune 100 clients, Kiran brings a full-lifecycle perspective to every project &amp;mdash; spanning architecture, discovery, estimation, and go-live planning &amp;mdash; working closely with tech leads and development teams to bring solutions to life.&lt;/p&gt;
&lt;p&gt;He currently focuses on Optimizely SaaS CMS implementations and large-scale digital transformation initiatives, and holds both Optimizely SaaS CMS and Opal certifications. A long-time community contributor and knowledge sharer, Kiran is passionate about helping practitioners learn from real-world experience rather than just documentation, and is especially excited about what SaaS CMS, Opal, and Experimentation can unlock for marketing teams.&lt;/p&gt;
&lt;div class=&quot;MsoNormal&quot; align=&quot;center&quot;&gt;&lt;hr /&gt;&lt;/div&gt;
&lt;h2&gt;William Parr&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://images2.cmp.optimizely.com/Zz02YWM1MTJhNmFkMDgxMWYxYWE2ZmZlNjA2ZGJlNWU2OQ==?checkExpiry=false&quot; alt=&quot;William - Profile photo - Square.jpg&quot; width=&quot;222&quot; height=&quot;222&quot; /&gt;&lt;/p&gt;
&lt;p&gt;With 18 years in web development, William Parr is a Lead Engineer and Optimizely Chapter Lead at iO, specialising in translating client requirements into scalable solutions and bridging business needs with technical implementation.&lt;/p&gt;
&lt;p&gt;His expertise spans back-end development, enterprise content management, and DXP architectures, with a primary focus on Optimizely. An active community contributor, William holds certifications including Optimizely Certified CMS Developer, Optimizely One Technical Sales Accredited, and Opal Development Accredited.&lt;/p&gt;
&lt;div class=&quot;MsoNormal&quot; align=&quot;center&quot;&gt;&lt;hr /&gt;&lt;/div&gt;
&lt;h2&gt;Phil Heywood&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://images1.cmp.optimizely.com/Zz1jYTM5MGE2Y2FkMDgxMWYxODY3OTM2Mjk5NWRjNDhjNA==?checkExpiry=false&quot; alt=&quot;1659950706926.jpeg&quot; width=&quot;229&quot; height=&quot;229&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Phil Heywood is Head of Experience at Netcel, working at the strategy and experience end of digital programmes rather than the build. He focuses on framing business outcomes clearly and building a Goal Tree so a programme stays anchored to them. Using Core Modelling, Phil identifies the &quot;core pages&quot; where a priority user task meets a priority business goal &amp;mdash; insight that then feeds the CMS and DXP content model and Optimizely Graph&#39;s composable structure.&lt;/p&gt;
&lt;p&gt;He sets experimentation and personalisation strategy across Web Experimentation and Feature Experimentation, helping clients move up the maturity curve, and owns Netcel&#39;s experience-optimisation practice. Phil also advises organizations on where Opal and agentic workflows can remove friction from everyday content and optimisation work.&lt;/p&gt;
&lt;div class=&quot;MsoNormal&quot; align=&quot;center&quot;&gt;&lt;hr /&gt;&lt;/div&gt;
&lt;h2&gt;Nicholas Sideras&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://images4.cmp.optimizely.com/Zz1iNDJkOGI3MGFkMDkxMWYxYWUzMWFlMjRkNGFmYmZjMw==?checkExpiry=false&quot; alt=&quot;DSC_0113.JPG&quot; width=&quot;231&quot; height=&quot;231&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Nicholas Sideras is a CMS Architect and Optimizely Platform Lead at Velir, a full-service digital agency and data studio, where he sets technical direction for CMS implementation projects. He started working in the CMS space twenty years ago, with over twelve years of experience on integration-heavy e-commerce projects connecting websites to back-office OMS, ERP, product information, and payment systems. While launching sites on the Optimizely platform over the past seven years, he has earned ten active certifications, including SaaS CMS, PaaS CMS, Opal, and Web Experimentation.&lt;/p&gt;
&lt;p&gt;Nicholas has held both in-house and agency engineering roles, working with organizations ranging from local businesses to global brands. That experience &amp;mdash; including client-side management of agencies &amp;mdash; has taught him the weight digital projects place on sponsoring teams, a perspective he brings to his client work and to the technical writing and talks he shares with the community.&lt;/p&gt;
&lt;div class=&quot;MsoNormal&quot; align=&quot;center&quot;&gt;&lt;hr /&gt;&lt;/div&gt;
&lt;h2&gt;Keshav Dave&amp;nbsp;&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://images2.cmp.optimizely.com/Zz04ODMwMmQyMGFkMzExMWYxOGU2YTRhODRhOWE3YTg5NQ==?checkExpiry=false&quot; alt=&quot;KeshavProfilePhoto.jpg&quot; width=&quot;239&quot; height=&quot;247&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Keshave Dave is a Senior Lead Optimizely Engineer working with RAKBANK in Dubai, with 10 years of experience working with the Optimizely platform and digital experience solutions. He has hands-on experience across the Optimizely ecosystem, including CMS SaaS/PaaS, Commerce Connect, Optimizely Graph, Web and Feature Experimentation, Optimizely Opal, and other Optimizely products. He also has extensive experience building integrations using Optimizely APIs and services, as well as designing and developing scalable content and commerce solutions.&lt;/p&gt;
&lt;p&gt;Beyond his project work, he&#39;s passionate about exploring new Optimizely capabilities and contributing to the Optimizely community through technical knowledge sharing, blogs, community support, and Dev Meetups.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Muhammad Talha&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://images2.cmp.optimizely.com/Zz1jNzAxMTM2NmFkMDkxMWYxYThkNmRlMjJhYTdkZDM5MQ==?checkExpiry=false&quot; alt=&quot;Talha pic2.png&quot; width=&quot;153&quot; height=&quot;331&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Muhammad Talha brings a customer-centric mindset to digital transformation and digital experience programs, having led initiatives for renowned brands across professional services, consumer products, insurance, and asset management.&lt;/p&gt;
&lt;p&gt;He works closely with business stakeholders to define digital strategies and roadmaps that maximize the value of the Optimizely platform, guiding customers on the effective adoption of Optimizely CMS, Commerce, and Digital Experience solutions while leading end-to-end delivery from ideation and solution design through to production release. Talha also supports customers on their AI transformation journey, enabling the adoption of Optimizely Opal and identifying opportunities for AI-driven capabilities to enhance content management, digital experiences, and operational efficiency.&lt;/p&gt;
&lt;div class=&quot;MsoNormal&quot; align=&quot;center&quot;&gt;&lt;hr /&gt;&lt;/div&gt;
&lt;h2&gt;Mark Ames&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://images3.cmp.optimizely.com/Zz1lM2M0MDQ0MGFkMDkxMWYxOWI5ODk2Y2YyYzcyODk5ZQ==?checkExpiry=false&quot; alt=&quot;1516318912031.jpeg&quot; width=&quot;208&quot; height=&quot;208&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Mark Ames is a Technical Director at Netcel, has spent 15 years shaping how organisations build on Optimizely, from Episerver&#39;s earliest days to today&#39;s agentic, AI-accelerated platform. His work sits where technical direction meets architectural governance: guiding teams through digital transformations, complex integrations, and version migrations, and making the hard calls on where a platform should be pointed to deliver real value.&lt;/p&gt;
&lt;p&gt;Lately, that focus has turned to AI. Mark has developed a structured, human-in-the-loop approach to AI-accelerated Optimizely delivery, championing what agentic tooling like Opal can genuinely do, while keeping a clear head about where human judgement has to stay in the loop. He led Netcel&#39;s Optimizely Opal hackathon effort, contributes thoughtfully to technical discussions across Optimizely World, and works quietly behind the scenes to mentor colleagues and widen his team&#39;s footprint in the community. Generous with hard-won lessons, Mark is exactly the kind of practitioner the OMVP program exists to recognise.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Wojciech Seweryn&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://images1.cmp.optimizely.com/Zz1jMjYyM2MxMmFkMGExMWYxODY3OTM2Mjk5NWRjNDhjNA==?checkExpiry=false&quot; alt=&quot;1517553835453.jpeg&quot; width=&quot;240&quot; height=&quot;240&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Wojciech Seweryn is the kind of developer who turns hard-won project experience into something the whole community can use. As Lead Backend Developer at NoA Ignite, he&#39;s spent more than eight years deep in the Optimizely platform &amp;mdash; currently leading backend work on The Royal Mint, where he owns everything from architecture to go-live across CMS, Commerce, ODP, Product Recommendations, and both Web and Feature Experimentation, including a heavily customized Commerce Bundles build.&lt;/p&gt;
&lt;p&gt;But it&#39;s what he does beyond the code that stands out. On his blog at wseweryn.dev, Wojciech has published a steady run of practical, no-nonsense technical guides on ODP real-time audiences, CMS 13 upgrades, Graph migration, and Product Recommendations customization, the kind of writing that saves other developers hours of trial and error. He&#39;s just as active in the forums, where his answers have earned rare &quot;accepted&quot; marks from the people he&#39;s helped. Generous, precise, and consistently useful, Wojciech is a natural fit for the OMVP program.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Niklas Andersson&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://images2.cmp.optimizely.com/Zz1mMjk3MDU0NGFkMDkxMWYxYTgyZTRhODRhOWE3YTg5NQ==?checkExpiry=false&quot; alt=&quot;Niklas profilbild_nylle.JPG&quot; width=&quot;203&quot; height=&quot;270&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Niklas Andersson spent more than eight years inside Optimizely, &amp;nbsp;back when it was still called Episerver &amp;mdash; building teams around Customer Success, Support, Partner Success, and Managed Services. Today he leads the Optimizely practice at Precio Fishbone in Stockholm, moving from asking partners to do great work to delivering it himself.&lt;/p&gt;
&lt;p&gt;He is currently deep in Opal, GEO/AEO, and figuring out how AI fits into real enterprise DXP setups without breaking anything important. Outside of work, Niklas is usually on a boat, underwater, or being politely folded up on a Brazilian Jiu-Jitsu mat.&lt;/p&gt;
&lt;div class=&quot;MsoNormal&quot; align=&quot;center&quot;&gt;&lt;hr /&gt;&lt;/div&gt;
&lt;h2&gt;Rikard Ljungman&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://images2.cmp.optimizely.com/Zz0wZjUzMTA5MmFkMGExMWYxYThkNmRlMjJhYTdkZDM5MQ==?checkExpiry=false&quot; alt=&quot;Rikard portrait.png&quot; width=&quot;221&quot; height=&quot;292&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Rikard Ljungman has been around Optimizely since the EPiServer days in 2007, and has seen the platform move through several eras &amp;mdash; this one, he believes, being the biggest.&lt;/p&gt;
&lt;p&gt;At Niteco, he focuses on helping clients make genuine sense of Opal and the AI shift: not just adopting new tools, but rethinking how teams work now that a lot of what used to require a developer no longer does. That is what he wants to bring to the OMVP program, helping people navigate what is genuinely new, informed by having watched what came before it.&lt;/p&gt;
&lt;div class=&quot;MsoNormal&quot; align=&quot;center&quot;&gt;&lt;hr /&gt;&lt;/div&gt;
&lt;h2&gt;Arnold Macauley&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;&lt;img src=&quot;https://images1.cmp.optimizely.com/Zz0zOWIyNjY3NmFkMGExMWYxOGMwYzQ2NWU2NjFkNjY1Yw==?checkExpiry=false&quot; alt=&quot;1579533942039.jpeg&quot; width=&quot;242&quot; height=&quot;242&quot; /&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Arnold Macauley is a Partner at Nansen leading the AI and Opal practice, owning strategy and architecture for agent work on Optimizely: which agents get built, in what order, and how they connect to CMS, CMP, and Experimentation. He runs the &quot;Prompts to Production&quot; workshop method &amp;mdash; moving from a blank page to around 60 use cases in under an hour &amp;mdash; and handles commercial shape, account leadership, and change management through delivery.&lt;/p&gt;
&lt;p&gt;Arnold built the Road Scholar Opal program, cutting an 81-hour sustainability review to five to seven minutes and reducing an activity-notes audit by 96% across 940 programs, with more than 40 everyday agents now live.&lt;/p&gt;
&lt;div class=&quot;MsoNormal&quot; align=&quot;center&quot;&gt;&lt;hr /&gt;&lt;/div&gt;
&lt;h2&gt;Karla Santi&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://images1.cmp.optimizely.com/Zz00OWZlODVkMmFkMGExMWYxOGI4MTQ2NWU2NjFkNjY1Yw==?checkExpiry=false&quot; alt=&quot;karla-bio-sq-26.jpg&quot; width=&quot;242&quot; height=&quot;242&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Karla Santi is the CEO and founding partner of Blend Interactive, a digital agency that guides organizations through complex web and content challenges. Since 2008, when Blend became Optimizely&#39;s first North American partner, she has grown the agency into an Optimizely Platinum Partner backed by a team of experts across Optimizely&#39;s solutions.&lt;/p&gt;
&lt;p&gt;Karla serves on Optimizely&#39;s Partner Advisory Board and is a frequent speaker and voice in the CMS community, bringing a strategic perspective on how organizations put technology to work for real results.&lt;/p&gt;
&lt;div class=&quot;MsoNormal&quot; align=&quot;center&quot;&gt;&lt;hr /&gt;&lt;/div&gt;
&lt;div&gt;
&lt;h2&gt;Augusto Davalos&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://images3.cmp.optimizely.com/Zz1kM2E0YzEwY2FkMGExMWYxYmIyYTUyZjZlMWM2ZjY2Mg==?checkExpiry=false&quot; alt=&quot;ADavalos-2024-2 square.jpg&quot; width=&quot;235&quot; height=&quot;244&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Augusto Davalos is a Senior Project Manager, Agile practitioner, and digital experience strategist with more than 15 years of experience helping teams turn business strategy into measurable outcomes. He is passionate about Lean and Agile thinking, reducing waste, and making sure teams focus their effort on work that creates real value.&lt;/p&gt;
&lt;p&gt;For more than four years, Augusto has been part of Oshyn, where he has worked on and led Optimizely initiatives ranging from ongoing maintenance and platform upgrades to migrations and broader digital experience projects. His focus is always on connecting technology decisions with business goals, delivery realities, and the people responsible for making change happen. As an Optimizely enthusiast, he enjoys sharing practical lessons from real projects and helping organizations get more value from their digital experience platforms while contributing back to the community.&lt;/p&gt;
&lt;h1&gt;Welcome to the OMVP Community&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;The Optimizely community thrives because of individuals who are willing to share their expertise, mentor others, and push the platform forward. This new cohort of OMVPs embodies exactly that spirit.&lt;/p&gt;
&lt;p&gt;This announcement highlights just a portion of the talent and dedication that makes the Optimizely community so strong. Our OMVPs represent a wide range of roles, industries, and regions, but they all share a common passion: helping others succeed with the Optimizely platform.&lt;/p&gt;
&lt;p&gt;We&#39;re excited to see how this new cohort will continue to contribute their expertise, share practical knowledge, and inspire the broader ecosystem in the months ahead.&lt;/p&gt;
&lt;p class=&quot;MsoNormal&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&#128073;&amp;nbsp;&lt;a href=&quot;/link/f9d3b8a331384c6da2f4f86861113acc.aspx&quot;&gt;&lt;strong&gt;Know someone who should be an OMVP? Encourage them to apply or nominate them directly&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/raiyan-binta-abdullah/dates/2026/9/meet-the-latest-omvps---h2-2026-cohort/</guid>            <pubDate>Thu, 10 Sep 2026 12:07:34 GMT</pubDate>           <category>Blog post</category></item><item> <title>Getting Started with Forms in Optimizely SaaS CMS</title>            <link>http://optimizelyfoundation.com/?p=632</link>            <description>Getting Started with Forms in Optimizely SaaS CMS.</description>            <guid>http://optimizelyfoundation.com/?p=632</guid>            <pubDate>Tue, 08 Sep 2026 10:03:30 GMT</pubDate>           <category>Blog post</category></item><item> <title>Two more database surprises when upgrading from CMS 11 to CMS 13</title>            <link>https://world.optimizely.com/blogs/Per-Nergard/Dates/2026/9/two-more-database-surprises-when-upgrading-from-cms-11-to-cms-13/</link>            <description>&lt;p&gt;Back in June I wrote about four database surprises when upgrading from CMS 11 to CMS 13 (&lt;a href=&quot;/link/5cc18ffcc5d4409aa0775dbc78343c20.aspx&quot;&gt;old post&lt;/a&gt;). Here are two more, found on another CMS 11 upgrade. Both come from the same place &amp;mdash; the site name &amp;mdash; and both break the new Application Manager while the public site keeps working as if nothing were wrong.&lt;/p&gt;
&lt;p&gt;The background for both: the CMS 13 upgrade copies your site name verbatim from tblSiteDefinition into the new tblApplication table, where it becomes the application&#39;s identifier &amp;mdash; with rules CMS 11 never had.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Whitespace in the site name: Application Manager won&#39;t even load the site&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If the name has a trailing space (&quot;My Swedish Site &quot; &amp;mdash; easy to have sat unnoticed for years), every attempt to view the site in Application Manager fails with:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;System.NullReferenceException&lt;/code&gt;&lt;br /&gt;&lt;code&gt;&amp;nbsp; &amp;nbsp;at EPiServer.Cms.UI.Admin.ApplicationManager.Internal.CompositeApplicationRepository.ToModel(Application application)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The name is round-tripped as a URL route key, where the trailing space is lost. The repository lookup on the other side is an exact dictionary hit (StringComparer.OrdinalIgnoreCase &amp;mdash; forgives casing, not whitespace), so it returns null, and ToModel dereferences it without a guard.&lt;/p&gt;
&lt;p&gt;This one is sneaky twice over: the public site keeps working (content binds via GUIDs, host resolution is unaffected), and the stack trace ends in whatever middleware sits first in your pipeline &amp;mdash; pointing you at your own code instead of the actual problem.&lt;/p&gt;
&lt;p&gt;Fix: RTRIM the name columns and restart &amp;mdash; the application dictionary is cached under ep:apps. SQL in the gist below.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Swedish characters in the site name: Application Manager loads &amp;mdash; but nothing can be saved&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;With the whitespace gone, a name like &quot;My Swedish Sit&amp;eacute;&quot; loads and displays just fine. But try to save anything &amp;mdash; a new hostname, for example &amp;mdash; and you get a validation error saying the name isn&#39;t valid. Renaming the application in the UI doesn&#39;t help; the error keeps pointing at the old name.&lt;/p&gt;
&lt;p&gt;The reason is in EPiServer.Applications.Application:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;/// The name must start with a letter and can only contain&lt;/code&gt;&lt;br /&gt;&lt;code&gt;/// letters, digits, and underscores.&lt;/code&gt;&lt;br /&gt;&lt;code&gt;[Required]&lt;/code&gt;&lt;br /&gt;&lt;code&gt;[ResourceName] &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;// ^[A-Za-z][_0-9A-Za-z]+\z &amp;mdash; ASCII only&lt;/code&gt;&lt;br /&gt;&lt;code&gt;public string Name { get; private set; }&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The name is validated on every save against ^[A-Za-z][_0-9A-Za-z]+\z &amp;mdash; ASCII only. Any &amp;aring;, &amp;auml;, &amp;ouml; or space fails it. And the kicker: Name has a private setter, so it cannot be changed through the admin UI or any public API. Save clones the stored application &amp;mdash; old name and all &amp;mdash; validates the whole entity, and fails on the stored name no matter what you type. A migrated site with a non-ASCII name is permanently unmanageable in Application Manager until the name is fixed directly in the database.&lt;/p&gt;
&lt;p&gt;The working fix: set a new ASCII Name in tblApplication and keep the human-readable name in DisplayName, which only has a length limit and is what editors see. Nothing else is derived from the name &amp;mdash; hosts, entry point and assets bind via keys and their own columns &amp;mdash; so the rename is safe. Restart afterwards, same cache.&lt;/p&gt;
&lt;p&gt;Both issues are reported to Optimizely: the missing null guard, the missing name validation/normalization at migration, and the absence of any rename path for names the migration itself created in an invalid state.&lt;/p&gt;
&lt;p&gt;Wrapping up&lt;/p&gt;
&lt;p&gt;Same lesson as last time, with two more entries for the pre-flight runbook: check your site names before the first CMS 13 boot &amp;mdash; both for whitespace and against ^[A-Za-z][_0-9A-Za-z]+\z. Detection and fix SQL is in this gist &amp;mdash; as always, read it, understand it, and run it at your own risk: [&lt;a href=&quot;https://gist.github.com/PNergard/859566adbbd74547588445cbd8fd7fd0&quot;&gt;Gist&lt;/a&gt;]&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/Per-Nergard/Dates/2026/9/two-more-database-surprises-when-upgrading-from-cms-11-to-cms-13/</guid>            <pubDate>Tue, 08 Sep 2026 09:45:31 GMT</pubDate>           <category>Blog post</category></item></channel>
</rss>