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.
| Mechanism | Layer | Knob | Old-client effect |
|---|---|---|---|
| Additive endpoint versioning | Workflow | /app/v6..v15/init, each a superset | Ignores new top-level keys |
| Content_bit allowlist + semver floor | Content | SUPPORTED_BIT_TYPES_BY_APP per app/version | Unsupported type → HTTP 422 UNSUPPORTED_BIT_TYPE |
| Feature-flag + platform gating | Workflow | gate.flag, gate.platforms_exclude, config.enabled | Gated step/workflow removed server-side (fail-open/fail-safe) |
| Axis | Client-side skip (Alphazed FE) | Server-side gate (Alphazed BE) |
|---|---|---|
| Where decided | In the renderer, at render time | In Flask, before the JSON is sent |
| Unknown section / bit | warn-once + render null | filtered out of the payload |
| Unknown widget | StepHost auto-advances the graph | gated step → config.enabled=false |
| Newer bit on old client | web allowlist → EmptyState | semver floor → HTTP 422 |
| Whole broken flow | (renderer cannot help) | validation fails → workflow omitted → legacy fallback |
| Best when | one client, you control it | many 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 purposefulEmptyStateinstead of a blank frame. - Server-side mechanism 1:
/app/v6..v15/initis strictly additive, so old clients ignore new top-level keys; workflowids carry version suffixes for clean funnels. - Server-side mechanism 2:
SUPPORTED_BIT_TYPES_BY_APPuses semver version floors; an unsupported type raises HTTP 422UNSUPPORTED_BIT_TYPEinstead 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=falsewith 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
typeis 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
EmptyStaterather 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.