Skip to content

Proof of Concept: User Routing for WNP#1595

Open
slightlyoffbeat wants to merge 9 commits into
mainfrom
danb/research-wnp
Open

Proof of Concept: User Routing for WNP#1595
slightlyoffbeat wants to merge 9 commits into
mainfrom
danb/research-wnp

Conversation

@slightlyoffbeat

@slightlyoffbeat slightlyoffbeat commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

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/)

  • Signal registry with cache_safe flag — server-side signals need Fastly VCL cache-key extension before they're safe in a Live rule; client-side signals are inherently cache-safe.
  • Rule evaluator with tri-state (matched / definitively-not / unresolved) logic and AND-across-conditions per rule.
  • Dispatcher — page-type-agnostic — returns either a 302 to a variant, a resolver page for client-side signals, or None (canonical serves).
  • Resolver page + client-side JS resolver with per-signal + global timeouts.
  • Auto-generated Signals reference page in the Wagtail admin.

Models (springfield/cms/models/routing.py)

  • RoutingRule — ParentalKey to WhatsNewPage2026, Orderable for drag-to-reorder priority, status enum (Draft / Live / Archived).
  • RoutingCondition — ParentalKey to RoutingRule. One or more per rule; all must match (AND semantics).
  • WhatsNewPage2026.routing_paused — emergency kill switch.

WNP wrapper (springfield/firefox/views.py::wnp_dispatch)

  • Enforces utm_source=update gate (Balrog post-update flow only). Organic traffic bypasses routing entirely.

Admin author tools

  • Nested "User Routing" admin menu (Rules snippet + Signals reference).
  • Router URL responses carry Link: <canonical>; rel="canonical" header so SEO/AEO consolidates on the canonical.
  • Redirect target URLs carry standardized analytics attribution params (routed_from, routed_rule, routed_mode=server|client). Incoming spoofed values stripped.
  • Descendant-only target-page validation — variants must be children of the canonical.
  • Preview mode: ?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

  1. springfield/cms/routing/signals.py — signal registry data model with the cache_safe flag.
  2. 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.
  3. springfield/cms/routing/evaluator.py — tri-state per-condition, AND-across-conditions with false-short-circuit, OR-across-rules.
  4. springfield/cms/models/routing.pyRoutingRule + RoutingCondition inline model, clean() validation (descendant target enforcement, per-condition signal validation).
  5. springfield/firefox/views.py::wnp_dispatch — WNP-specific policy (utm_source=update gate). The $ URL anchor in urls.py is load-bearing (regression test guards against the redirect loop it caused during development).
  6. 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:

  1. Fastly VCL cache-key extension (Websites team) for whichever server-side signals the rule references. Signal.cache_safe flag flips to True after VCL is in place. Coordinate before the first server-side rule goes Live.
  2. RoutingRule.clean() enforcement of cache_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.
  3. MarTech alignment on analytics param names (routed_from, routed_rule, routed_mode) before ads reference them — these are hardcoded emissions.

How to test

Setup

./manage.py migrate
./manage.py runserver

1. Create pages in Wagtail admin (/cms-admin/)

Under Pages → What's New Index (create one if missing):

  1. Add child page: What's New Page 2026 with slug 120 → publish. This is the canonical.
  2. Add another What's New Page 2026 as a child of the 120/ page with slug e.g. us-variant → publish. This is a variant. (Variant must be a descendant of canonical — enforced by clean().)

2. Attach a routing rule to the canonical

Edit the canonical /whatsnew/120/, go to the User Routing tab:

  • Rule name: e.g. us-users-120
  • Target page: the us-variant you created
  • Add a condition: signal country, operator is, values US
  • Status: Live
  • Save → Publish.

3. Verify the gate (utm_source=update policy)

Set DEV_GEO_COUNTRY_CODE=US, restart the server:

  • Visit http://localhost:8000/en-US/whatsnew/120/ → canonical. Rule would match, but the gate blocks routing.
  • Visit http://localhost:8000/en-US/whatsnew/120/?utm_source=organic → still canonical.

4. Verify server-side rule matching

With DEV_GEO_COUNTRY_CODE=US:

  • Visit http://localhost:8000/en-US/whatsnew/120/?utm_source=update → 302 to /whatsnew/120/us-variant/. Target URL includes routed_from=120&routed_rule=us-users-120&routed_mode=server. Original query string preserved.
  • Response has Link: rel="canonical" header pointing at the canonical URL.
  • Change env to 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, operator is, values true. Save.

  • Visit .../whatsnew/120/?utm_source=update (no oldversion) → canonical (lapsed_user unresolved → AND-rule blocks).
  • Visit .../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:8000

Change the rule's condition to signal default_browser, values true. Visit http://localhost:8000/en-US/whatsnew/120/?utm_source=update in Firefox → briefly shows the resolver page (has Link: rel="canonical" header), then navigates to the variant with routed_mode=client in 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 is Cache-Control: no-store.
  • .../whatsnew/120/?preview_signal=country:DE&utm_source=update → canonical (fake signal makes the rule miss). no-store.
  • Preview URLs when logged OUT are silently ignored (canonical serves).

8. Verify pause routing kill switch

Toggle routing_paused on 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, status columns.

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_dispatch

Not in this PR

Follow-up work explicitly out of scope for V1:

  • Signal.cache_safe enforcement in RoutingRule.clean() — backend engineer follow-up.
  • Second page-type consumer (landing pages, download, etc.) — the framework's dispatch_for_canonical is page-type-agnostic, but RoutingRule.parent_page is a ParentalKey hard-coded to WhatsNewPage2026 (Wagtail's InlinePanel machinery needs a concrete Page subclass). Adopting a second consumer requires design work — either broadening the parent to a generic Page FK with different inline UX, or splitting per-parent-type. See the model comment.
  • On-page router mode / non-WNP ?r=1 marker — the editor-picks-mode UX is designed but not built. Post-V1.
  • Surrogate-key tagging for atomic rule-publish invalidation — Websites team follow-up.
  • Rule expiry (starts_at / expires_at) — Post-V1.
  • A/B split within a rule — Post-V1.
  • In-page swaps (same-URL variant content) — Post-V1. Framework has supports_in_page_swap on Signal but no consumer.
  • Copyable Router URL widget in the routing panel, deletion guardrail modal — Post-V1 UX polish.

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 &amp;
- 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
@slightlyoffbeat slightlyoffbeat added the Do Not Merge Don't do it. label Jul 9, 2026
]

operations = [
migrations.RemoveField(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.15449% with 28 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.12%. Comparing base (b72dce3) to head (0ad830a).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
springfield/cms/routing/evaluator.py 81.66% 11 Missing ⚠️
springfield/cms/models/routing.py 96.18% 5 Missing ⚠️
springfield/cms/routing/dispatcher.py 92.06% 5 Missing ⚠️
springfield/cms/routing/server_resolvers.py 93.42% 5 Missing ⚠️
springfield/cms/routing/signals.py 96.92% 2 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@stevejalim stevejalim left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

@stephaniehobson stephaniehobson Jul 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 stephaniehobson left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'. "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Enhancement, non-blocking): It'd be nice to link to the help entry here.

Comment thread springfield/cms/models/routing.py Outdated
``{signal, op, values}`` shape, decomposing either into the
structured fields.

Legacy support preserves the fluent ``RoutingRule(condition={...})``

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo (blocking): Don't forget to include the UI Tour library ;)

return False


def evaluate_rules(rules: Iterable, resolved_signals: dict[str, Any]) -> EvaluationResult:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 stephaniehobson left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two high-level pieces of feedback:

  1. 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
  1. 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Do Not Merge Don't do it.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants