Proof of Concept: User Routing for WNP#1595
Conversation
Add WNP dynamic-rendering prototypes and research
Two isolated prototypes at /whatsnew/dispatch/*, not wired to real WNP:
- timing-test: measures UITour ping + getConfiguration latency
- proto/{version}/: end-to-end server-eval + resolver-page + variant flow
Local validation: UITour ~2ms per call when it responds; server-side rule
match is 61ms dispatch to variant; client-side match via resolver page is
~86ms end-to-end. aiControls hangs on Fx 151, drove per-signal-timeout
requirement in the design.
Display renames (no code identifier changes): - Sidebar entry: "Routing Rules" -> "User Routing" - Verbose name: "Routing Rule" -> "User Routing rule" - Viewset class: RoutingRuleViewSet -> UserRoutingViewSet - Positioned to avoid confusion with Wagtail's built-in Redirects. Model refactor: - RoutingRule.parent_page is now a ParentalKey to WhatsNewPage2026, enabling Wagtail InlinePanel editing. Migration 0114. - Related name renamed from routing_rules_as_parent -> routing_rules (wnp_dispatch and tests updated). Admin UX: - WhatsNewPage2026 gets a new "User Routing" tab via TabbedInterface, hosting an InlinePanel of its rules alongside a help note. - Global "User Routing" sidebar entry stays as a cross-reference view.
- Client-side resolver page for UITour-mediated rules - Three-field condition editor (signal / operator / values); no JSON - is / is_not operators with list-valued expected values - country / ai_controls / platform / os_version enum-validated at save - Auto-generated Signals reference page in the admin - WNP wrapper enforces utm_source=update gate; generic dispatcher stays policy-free for future page-type consumers - Trailing $ on WNP URL patterns fixes a variant-URL redirect loop - Migrations 0115 & 0116 replace condition JSONField with structured signal_name / operator / expected_values fields
- Restore Stub Attribution panel on WNP Promote tab (TabbedInterface was bypassing UTMParamsMixin) - Escape </, <!--, --> in <script>-embedded JSON blobs (fixes XSS via admin-editable rule names; uses valid JSON escapes so JSON.parse doesn't crash) - Emit canonical_url as JSON so Jinja autoescape can't turn & into & - Client-side evaluate() waits for higher-priority pending rules before returning a lower-priority match - Scope evergreen redirect lookup to direct children of the index (variant slugged 'general' etc. can no longer false-positive) - _append_qs uses urllib.parse so URL fragments don't strand querystrings - Log when a matched rule has no target URL, and when live rules can't be evaluated - Delete prototype code (views_prototype_wnp.py + templates + JS + URL patterns + bundle entries) - Rename dispatch_for_canonical's target_version kwarg to a generic signal_context dict
| ] | ||
|
|
||
| operations = [ | ||
| migrations.RemoveField( |
There was a problem hiding this comment.
We should get claude to drop and regenerate these migrations so that there's no unnecessary additon of a field (in 0113) then removal of it here.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1595 +/- ##
==========================================
+ Coverage 84.95% 87.12% +2.17%
==========================================
Files 152 157 +5
Lines 10519 10737 +218
==========================================
+ Hits 8936 9355 +419
+ Misses 1583 1382 -201 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Sorry - blocking merge on this until we can work out the CDN implications. Right now, I think this won't fly because as far as the CDN is concerned, it may not be able to differentiate reliably between request for a WNP with content decided by server-side rules and one that's handled client-side - and especially so if there's a combination of both URL-params and UITour signals.
Will have a think about it, and I might be wrong, but flagging this early in case it got merged via someone else's approval
| </head> | ||
| <body> | ||
| <main> | ||
| <p id="user-routing-status">Preparing your page…</p> |
There was a problem hiding this comment.
This needs a <noscript> fallback.
I also want to note that the UITour check can add several seconds to the response and we should monitor to make sure that doesn't result in fewer total views.
stephaniehobson
left a comment
There was a problem hiding this comment.
Commenting to block this until I've done a full PR review.
| "wagtailcore.Page", | ||
| on_delete=models.PROTECT, | ||
| related_name="routing_rules_as_target", | ||
| help_text="The variant page to route matching users to.", |
There was a problem hiding this comment.
Should this be restricted to only child pages of this canonical page?
| help_text=( | ||
| "One or more values, one per line (commas also accepted). " | ||
| "For enum-valued signals, values must match the exact allowed set " | ||
| "(check the signal's help entry). Booleans: 'true' or 'false'. " |
There was a problem hiding this comment.
(Enhancement, non-blocking): It'd be nice to link to the help entry here.
| ``{signal, op, values}`` shape, decomposing either into the | ||
| structured fields. | ||
|
|
||
| Legacy support preserves the fluent ``RoutingRule(condition={...})`` |
There was a problem hiding this comment.
Todo (blocking):
Since this code is net-new there's no "legacy" signal to preserve. Please re-write the tests to consider only the structure we plan to publish.
| try { | ||
| window.location.replace(url); | ||
| } catch (e) { | ||
| window.location.href = url; |
There was a problem hiding this comment.
Bug (blocking): If there is no matching variant, the user will be sent back to the original URL... which will check for variants again.
From the PR description, it sounds like you ran into this problem during development with the child pages as well and only fixed it there.
Maybe appending a param to the query string so it doesn't loop.
| } | ||
|
|
||
| if ( | ||
| !window.Mozilla || |
There was a problem hiding this comment.
todo (blocking): Don't forget to include the UI Tour library ;)
| return False | ||
|
|
||
|
|
||
| def evaluate_rules(rules: Iterable, resolved_signals: dict[str, Any]) -> EvaluationResult: |
There was a problem hiding this comment.
At the moment this function is only resolving server side rules and higher-priority client-side rules are not part of the evaluation.
(So, if your first priority is to target people with Firefox set as their default browser but have a lower priority rule for people in GEO=de --People with Firefox set as default in de, will see the de page, not the set as default one.
There was a problem hiding this comment.
I think this means that the client side resolver will have to be able to handle all server side cases.
Which begs the question - why do all the work twice? Maybe we should only have the client side resolver (good for maintainability, not necessarily for page speed)
| if locale is None: | ||
| return _fallback() | ||
|
|
||
| canonical = WhatsNewPage2026.objects.live().public().filter(locale=locale, slug=version).first() |
There was a problem hiding this comment.
This needs the same filters that scope the slug lookup to the WhatsNewIndexPage's direct children as are on the _get_evergreen_wnp_redirect (line 1113ish)
stephaniehobson
left a comment
There was a problem hiding this comment.
Two high-level pieces of feedback:
- We need more defined parent/child scoping.
- children should only be allowed at depth 1, not infinitely nested
- children should not allow user routing rules to be defined
- choosing variant pages would be faster if the routing only included child pages in the page selector
- We need a strategy for handling a mix of server and client side rules
- maybe all processing happens client side? (though, I do like the speed of the server side processing, but I don't like duplicating the rules more than once)
- maybe server can evaluate true/false for all its rules and then pass to the client for final evaluation if there are client rules defined
- Signal.cache_safe flag on the registry (country → False; client-side → True). Enforcement in clean() is a follow-up owned by the backend engineer. - Link: rel="canonical" header on router responses (absolute URL) so SEO/AEO consolidates on the true canonical. - routed_from / routed_rule / routed_mode params on redirect targets for analytics attribution. Incoming spoofed values stripped. - RULE_STATUS_RETIRED renamed to RULE_STATUS_ARCHIVED (migration 0117). - RoutingRule.clean() rejects non-descendant target pages; test fixtures updated so variants are children of canonical.
- Orderable priority (sort_order via Wagtail Orderable, drag-to-reorder) - AND conditions via new RoutingCondition inline (ParentalKey to Rule) - Preview mode: ?preview_rule / ?preview_signal (admin-auth, no-store), bypasses pause switch so authors can verify while routing is halted - Pause routing kill switch (routing_paused) on WhatsNewPage2026 - Code review fixes: unresolvable-target rules dropped from resolver page (was: JS navigation loop), preview_signal accepts admin bool vocabulary (yes/no/1/0), no-store applies whenever preview_rule is present, wagtail_hooks list_display references sort_order, reverse migration preserves sort_order=0
User Routing — Phase 1
Adds a first-class routing layer that lets marketing authors send users to different variant pages based on signals (country, locale, Firefox version, default-browser status, etc.) via typed rules in Wagtail admin — not by handwriting conditional rendering inside CMS content blocks. WNP is the first consumer. Framework is page-type-agnostic so landing pages / downloads can adopt it next.
Signals resolve server-side when possible (country, locale, platform, ...) and client-side via UITour (default-browser, firefox-pinned, ai-controls, ...) when the browser has to answer. Rules are AND-composable within a rule, OR-prioritized across rules.
What ships
Framework (
springfield/cms/routing/)cache_safeflag — server-side signals need Fastly VCL cache-key extension before they're safe in a Live rule; client-side signals are inherently cache-safe.None(canonical serves).Models (
springfield/cms/models/routing.py)RoutingRule— ParentalKey toWhatsNewPage2026,Orderablefor drag-to-reorder priority, status enum (Draft / Live / Archived).RoutingCondition— ParentalKey toRoutingRule. One or more per rule; all must match (AND semantics).WhatsNewPage2026.routing_paused— emergency kill switch.WNP wrapper (
springfield/firefox/views.py::wnp_dispatch)utm_source=updategate (Balrog post-update flow only). Organic traffic bypasses routing entirely.Admin author tools
Link: <canonical>; rel="canonical"header so SEO/AEO consolidates on the canonical.routed_from,routed_rule,routed_mode=server|client). Incoming spoofed values stripped.?preview_rule={id}(force-match a specific rule for target rendering) and?preview_signal={name}:{value}(repeatable; feed fake values to the evaluator). Admin-authenticated,Cache-Control: no-store, bypasses pause routing.Where to focus review
springfield/cms/routing/signals.py— signal registry data model with thecache_safeflag.springfield/cms/routing/dispatcher.py— the main entry point. Note the ordering of preview handling / pause switch / evaluator, and the canonical-Link header + analytics params on both server-side redirects and client-side (resolver-page) rules.springfield/cms/routing/evaluator.py— tri-state per-condition, AND-across-conditions with false-short-circuit, OR-across-rules.springfield/cms/models/routing.py—RoutingRule+RoutingConditioninline model,clean()validation (descendant target enforcement, per-condition signal validation).springfield/firefox/views.py::wnp_dispatch— WNP-specific policy (utm_source=update gate). The$URL anchor inurls.pyis load-bearing (regression test guards against the redirect loop it caused during development).media/js/cms/user-routing-resolver.js— AND-across-conditions with tri-state + priority-strict ordering (higher-priority pending rules block lower-priority matches).Coordination required before any rule goes Live
The framework ships with no rules configured against any production page. Before flipping any Live rule:
Signal.cache_safeflag flips toTrueafter VCL is in place. Coordinate before the first server-side rule goes Live.RoutingRule.clean()enforcement ofcache_safe(backend engineer follow-up) — reject rules referencing non-cache-safe signals server-side. Currently the flag is declarative only; the follow-up wires it into save-time rejection.routed_from,routed_rule,routed_mode) before ads reference them — these are hardcoded emissions.How to test
Setup
1. Create pages in Wagtail admin (
/cms-admin/)Under Pages → What's New Index (create one if missing):
120→ publish. This is the canonical.120/page with slug e.g.us-variant→ publish. This is a variant. (Variant must be a descendant of canonical — enforced byclean().)2. Attach a routing rule to the canonical
Edit the canonical
/whatsnew/120/, go to the User Routing tab:us-users-120us-variantyou createdcountry, operatoris, valuesUSLive3. Verify the gate (
utm_source=updatepolicy)Set
DEV_GEO_COUNTRY_CODE=US, restart the server:http://localhost:8000/en-US/whatsnew/120/→ canonical. Rule would match, but the gate blocks routing.http://localhost:8000/en-US/whatsnew/120/?utm_source=organic→ still canonical.4. Verify server-side rule matching
With
DEV_GEO_COUNTRY_CODE=US:http://localhost:8000/en-US/whatsnew/120/?utm_source=update→ 302 to/whatsnew/120/us-variant/. Target URL includesrouted_from=120&routed_rule=us-users-120&routed_mode=server. Original query string preserved.Link: rel="canonical"header pointing at the canonical URL.DEV_GEO_COUNTRY_CODE=DE, restart, visit same URL → canonical (rule doesn't match).5. Verify AND conditions
Edit your rule and add a second condition: signal
lapsed_user, operatoris, valuestrue. Save..../whatsnew/120/?utm_source=update(no oldversion) → canonical (lapsed_userunresolved → AND-rule blocks)..../whatsnew/120/?utm_source=update&oldversion=115→ 302 to us-variant (both conditions match).6. Verify client-side matching (UITour signal)
Enable UITour on localhost (Firefox
about:config):browser.uitour.testingOrigins = http://localhost:8000Change the rule's condition to signal
default_browser, valuestrue. Visithttp://localhost:8000/en-US/whatsnew/120/?utm_source=updatein Firefox → briefly shows the resolver page (hasLink: rel="canonical"header), then navigates to the variant withrouted_mode=clientin the URL.7. Verify preview mode
While logged in to Wagtail admin:
.../whatsnew/120/?preview_rule={rule_id}&utm_source=update→ 302 to variant regardless of signal state. Response isCache-Control: no-store..../whatsnew/120/?preview_signal=country:DE&utm_source=update→ canonical (fake signal makes the rule miss).no-store.8. Verify pause routing kill switch
Toggle
routing_pausedon the canonical → Save..../whatsnew/120/?utm_source=update→ canonical (rules bypassed)..../whatsnew/120/?preview_rule={id}&utm_source=update(as admin) → still 302 to variant (preview bypasses pause).9. Verify Signals Reference page
Wagtail admin: User Routing → Signals reference — table lists all 12 signals with type (server / browser), value type, valid values, and a Cache-safe column (all server-side "No" for V1; client-side "Yes").
10. Verify Rules admin listing
Wagtail admin: User Routing → Rules — index shows
name,sort_order,parent_page,target_page,statuscolumns.11. Verify variant-URL loop guard
Visit the variant directly:
http://localhost:8000/en-US/whatsnew/120/us-variant/?utm_source=update→ renders the variant, no redirect loop.12. Run the tests
./manage.py test springfield.cms.routing springfield.cms.tests.test_routing_rule springfield.firefox.tests.test_wnp_dispatchNot in this PR
Follow-up work explicitly out of scope for V1:
Signal.cache_safeenforcement inRoutingRule.clean()— backend engineer follow-up.dispatch_for_canonicalis page-type-agnostic, butRoutingRule.parent_pageis aParentalKeyhard-coded toWhatsNewPage2026(Wagtail'sInlinePanelmachinery needs a concrete Page subclass). Adopting a second consumer requires design work — either broadening the parent to a genericPageFK with different inline UX, or splitting per-parent-type. See the model comment.?r=1marker — the editor-picks-mode UX is designed but not built. Post-V1.starts_at/expires_at) — Post-V1.supports_in_page_swaponSignalbut no consumer.