08

Forward Compatibility & Versioning

The crux of server-driven UI: what happens when the server sends a type the client has never heard of. • Two answers run at once — the client degrades gracefully, and the server gates by app version — because Alphazed ships one backend to many app versions.

The core question

Forward compatibility is the property that an older client keeps working when a newer server sends it something new. It is the make-or-break question of server-driven UI, because the whole point of SDUI is to ship UI changes from the server without waiting for every client to update — which guarantees that, sooner or later, a client receives a type it does not recognize.

There are exactly two places to answer that question, and Alphazed uses both. The client can degrade gracefully: notice the unknown type and skip it instead of crashing. The server can gate: know which client is asking and refuse to send it something it cannot render. A single-web-client product can lean almost entirely on the client; Alphazed serves many app versions at once, so it also pushes compatibility into the server.

The rest of the day is that forward-compat path: first the client-side rules (three levels, plus an asymmetry), then the three server-side mechanisms, then who should own which.

Client-side: warn-once and render null

The client's default answer to an unknown type is the gentlest possible: log it once and draw nothing. A warn-once is exactly that — a module-level Set remembers which type names have already been warned about, so the console shows one message per unknown type instead of one per frame. In production the warning is suppressed entirely; the render simply returns null.

// SectionRenderer.tsx — unknown section type degrades, never crashes
const warnedTypes = new Set<string>(); // module-level, one warning per type

export function SectionRenderer({ section, ctx }: SectionProps) {
  const Section = sectionRegistry[section.type];
  if (!Section) {
    if (__DEV__ && !warnedTypes.has(section.type)) {
      warnedTypes.add(section.type);
      console.warn(`[SDUI] unknown section type: ${section.type}`);
    }
    return null; // skip the one unknown region; the rest of the screen renders
  }
  return <Section {...section} ctx={ctx} />;
}

BitRenderer does the identical thing one level down for an unknown bit.type. The effect is that one unrecognized region disappears while everything around it still renders — a screen is never taken down by a single stray type.

Client-side: an unknown widget auto-advances the graph

The top level — the widget — needs a stronger answer than "render nothing", because a widget is a whole step in a flow, and a blank step would dead-end the funnel: the user would be stuck on an empty screen with no way forward. So when StepHost cannot resolve step.widget, it does not render null — it auto-advances the graph.

// StepHost.tsx — an unknown widget must not dead-end a funnel
const SKIP_EVENTS = ['skip', 'complete', 'next'];

useEffect(() => {
  const Widget = widgetRegistry.resolve(step.widget);
  if (!Widget) {
    // advance the graph; replace the never-rendered history slot;
    // fire no side-effects, so no backend POST runs for a phantom step
    autoSkip(step, SKIP_EVENTS);
  }
}, [step.id]);

Two details keep this safe. The auto-advance replaces the never-rendered step in the history stack, so a later Back press cannot land on a phantom screen. And it fires no side-effects, so an unknown step can never accidentally trigger the backend POST that a real submit would. The funnel flows past the gap as if the step were a skip.

The asymmetry: structure hard-fails, vocabulary skips

There is one deliberate exception to "unknown things skip", and it is worth stating precisely because it looks like a contradiction. parseWorkflowDef hard-fails at parse time on structural errors — a missing id or start_step_id, a duplicate step id, a transition pointing at a step that does not exist, or a step whose type is not screen or gate. Everything else — an unknown widget name, section name, or bit name — skips.

The logic behind the split: a broken structure means the graph itself is unwalkable — there is no safe way to guess the author's intent, so failing loud at parse is correct. An unknown name is just a vocabulary gap — the graph is fine, one word is unfamiliar — so skipping that one thing and continuing is both safe and desirable. Structure is load-bearing; vocabulary is forward-compatible.

Client-side: the web platform allowlist

Some bit types are registered on the client but should not run on web specifically — they need native capabilities. An allowlist is a set of the types that are permitted; anything not on it is filtered out. WEB_PLAYABLE_BIT_TYPES is that set, and the key move is that it filters before render, not during it.

// filter unplayable bits BEFORE render → EmptyState, never a blank screen
const WEB_PLAYABLE_BIT_TYPES = new Set([
  'select-text', 'select-media', 'bubble-pop', 'click-to-pop', /* … */
]);

const isBitPlayable = (bit: ContentBit) =>
  Platform.OS !== 'web' || WEB_PLAYABLE_BIT_TYPES.has(bit.type);

const playable = filterPlayableBits(bits);        // drop web-unplayable first
if (playable.length === 0) return <EmptyState />;  // graceful, not blank

Filtering first matters: if every bit in a section is web-unplayable, the section knows the list is empty before it tries to render, so it can show a purposeful EmptyState instead of a blank frame. On native, any registered type plays, so the allowlist is a no-op there. This is still client-side degradation — the platform decides — but it is the client's cleanest fallback.

Server-side: additive endpoint versioning

Now the server side. The first mechanism is the oldest and simplest: the bootstrap endpoint is versioned and strictly additive. GET /app/vN/mobile-app/init exists at v6 through v15, and each version is a superset of the one before — new capabilities arrive as new top-level keys, never as changes to existing ones.

Because keys are only ever added, an old client that reads v6 simply never sees — and never breaks on — the keys introduced at v8, v11, or v15. A related discipline: each workflow's id carries a version suffix (post_sign_up_amal_v6), bumped on any content change, so analytics funnels always compare like with like across versions.

Server-side: the content_bit allowlist and semver floor

Additive keys protect the workflow layer, but the content layer needs something sharper, because a newer game type sent to an older client would render as a blank. The fix is a per-app, per-version allowlist keyed by a semver version floor — a rule like "these types are available to version 8.113.0 and above". packaging.version compares the client's version against each floor and picks the highest one it meets.

# app_version_supported_bit_types_config.py
SUPPORTED_BIT_TYPES_BY_APP = {
    "amal": {
        "__default__": ["select-text", "select-media", "fragment", "bubble-pop"],
        "8.113.0+":    ["select-text", "select-media", "fragment", "bubble-pop",
                        "sprite-drag-and-drop", "puzzle", "switching"],  # + newer games
    },
    "thurayya": {
        "__default__": ["select-text", "select-media"],
    },
}
# content_bit_type_allowlist.py
from packaging.version import Version

def supported_types_for(app: str, version: str) -> list[str]:
    table  = SUPPORTED_BIT_TYPES_BY_APP.get(app, {})
    floors = [k for k in table if k != "__default__"]
    best, best_floor = None, None
    for key in floors:                        # pick highest floor the client meets
        floor = Version(key.rstrip("+"))
        if Version(version) >= floor and (best_floor is None or floor > best_floor):
            best, best_floor = key, floor
    return table[best] if best else table.get("__default__", [])

When a byte contains a type the calling client is not cleared for, the server does not send a silent blank — it raises HTTP 422 UNSUPPORTED_BIT_TYPE, a loud, debuggable signal. This keeps roughly two dozen newer game types off older clients while still shipping them to 8.113.0 and up. (The strict raise is guarded by STRICT_BIT_TYPE_ALLOWLIST_ENABLED, default off, so the behavior can be rolled out deliberately.)

Server-side: feature-flag and platform gating

The third mechanism resolves at assembly time, on the server, before the JSON is ever sent. A feature flag is a runtime on/off switch for a capability; platform gating excludes a capability on certain platforms (say, web). Two design choices govern how they fail. Fail-open means a missing flag is treated as on, so a flag-service outage does not blank a flow. Fail-safe means an absent platform header is treated as "not excluded", so an unknown client still gets the workflow.

Gating happens at two grains. A whole workflow can be gated by gate.flag and gate.platforms_exclude; a single step can be gated by its own config.gate, in which case the server sets config.enabled=false but leaves its transitions intact — so the client advances straight through it on the complete edge without ever rendering it. And there is a final backstop: if a workflow fails server-side validation, the assembly omits its key entirely from init, and the client falls back to its built-in legacy flow. A broken workflow degrades to the old one rather than to a crash.

Who owns compatibility: skip vs gate

The two sides are not redundant; they cover different failures, and the right split depends on how many client versions you serve. The table names the three server-side mechanisms; the comparison then shows how the client-side skip and the server-side gate divide the work.

MechanismLayerKnobOld-client effect
Additive endpoint versioningWorkflow/app/v6..v15/init, each a supersetIgnores new top-level keys
Content_bit allowlist + semver floorContentSUPPORTED_BIT_TYPES_BY_APP per app/versionUnsupported type → HTTP 422 UNSUPPORTED_BIT_TYPE
Feature-flag + platform gatingWorkflowgate.flag, gate.platforms_exclude, config.enabledGated step/workflow removed server-side (fail-open/fail-safe)
AxisClient-side skip (Alphazed FE)Server-side gate (Alphazed BE)
Where decidedIn the renderer, at render timeIn Flask, before the JSON is sent
Unknown section / bitwarn-once + render nullfiltered out of the payload
Unknown widgetStepHost auto-advances the graphgated step → config.enabled=false
Newer bit on old clientweb allowlist → EmptyStatesemver floor → HTTP 422
Whole broken flow(renderer cannot help)validation fails → workflow omitted → legacy fallback
Best whenone client, you control itmany app versions in the wild

The contrast with case #1 makes the trade concrete. SpatialX asserts a safe fallback but is a single web client it controls end to end, so it can lean almost entirely on client-side skipping. Alphazed serves many app versions it cannot force-update, so it duplicates the guarantee on the server: the allowlist and gates make sure an old client is never even offered something it cannot render. The rule of thumb: skip on the client so nothing crashes; gate on the server so old clients are never sent what would crash them — and the more versions you serve, the more the server must carry.

Key takeaways

  • Forward compatibility is the crux of SDUI: because the server ships UI without waiting for clients, some client will always receive an unknown type, and both client and server need an answer.
  • Client-side, unknown section and bit names warn-once and render null; an unknown widget auto-advances the graph (replacing its history slot, firing no side-effects) so a funnel cannot dead-end.
  • The asymmetry is deliberate: structural errors hard-fail at parseWorkflowDef, but unknown widget/section/bit names skip — structure is load-bearing, vocabulary is forward-compatible.
  • The web allowlist (WEB_PLAYABLE_BIT_TYPES) filters unplayable bits before render, so an all-unplayable section shows a purposeful EmptyState instead of a blank frame.
  • Server-side mechanism 1: /app/v6..v15/init is strictly additive, so old clients ignore new top-level keys; workflow ids carry version suffixes for clean funnels.
  • Server-side mechanism 2: SUPPORTED_BIT_TYPES_BY_APP uses semver version floors; an unsupported type raises HTTP 422 UNSUPPORTED_BIT_TYPE instead of a silent blank.
  • Server-side mechanism 3: feature-flag and platform gating resolve server-side (fail-open, fail-safe); gated steps get config.enabled=false with transitions intact, and a workflow that fails validation is omitted so the client falls back to legacy.
  • Division of labor: a single controlled client can lean on client-side skipping; serving many app versions forces the guarantee onto the server via the allowlist and gates.

Checklist

  • [ ] I can state why an unknown type is inevitable in SDUI and name the two places to handle it.
  • [ ] I can explain warn-once + null and why an unknown widget must auto-advance instead.
  • [ ] I can describe the structure-vs-vocabulary asymmetry and justify why each side behaves as it does.
  • [ ] I can explain how the web allowlist yields an EmptyState rather than a blank screen.
  • [ ] I can describe additive endpoint versioning and why old clients stay safe.
  • [ ] I can read SUPPORTED_BIT_TYPES_BY_APP, explain a semver version floor, and say when a 422 is raised.
  • [ ] I can explain feature-flag/platform gating, fail-open vs fail-safe, and the omit-workflow legacy fallback.
  • [ ] I can contrast client-side skip with server-side gate and say which a single-client vs many-version product should lean on.