Problem
Discovery mints a second cache key for pages we already have, purely by re-spelling a space inside a
faceted query value. Measured from a 30-minute read_audit_log window on render_service.Target
(615 distinct discovery-created URLs, 93% catalog): 4.7% of discovered catalog URLs write a space
as + — ?CN=Color:Blue+Silhouette:Bath+Rugs where the declared spelling is Bath%20Rugs. That
is ~1,300/day, and the origin serves both spellings the same page.
#90 stops them becoming immortal targets, by reading the page's canonical against the cache key: the
re-spelling is a canonical-variant verdict and gets suppressed. But that is retirement, not
identity — it costs a render per URL, and since only 5 of 25 sampled canonical twins are in a
sitemap, usually neither spelling ends up prerendered and bots take the origin proxy for that page.
Collapsing the two spellings into one key is the better end state: the duplicate never exists, no
render is spent, and the crawler's +-spelled URL serves the cached page instead of falling
through to the origin.
The standard a normalization has to meet
We serve on behalf of the origin. We fetch and render the representative N(U) and then serve its
bytes for every U that maps to it, so the requirement is not "these two URLs look equivalent" but:
for every URL U we accept, origin(N(U)) is the same resource as origin(U).
Every rule in canonicalizeUrl is therefore a claim about the origin's URL semantics, and the claims
worth making are the ones settled at the parser boundary — where the decoder erases a distinction
before any application logic runs. Those hold for every URL, forever, and are established by one
discriminating probe per allowlisted query parameter, never by surveying pages. A claim that is
per-resource instead (does this path serve the same thing with a trailing slash? — #93) cannot be
established that way at all and should not be assumed.
This fold is a parser-boundary claim: the origin form-decodes the query. Under form-decoding +
and %20 are the same character before the application sees anything, so no page can distinguish
them — which is what makes the fold universally safe rather than safe-on-the-sample.
Why + ≡ %20 is safe to assert here
The origin form-decodes its query. The discriminator, re-run 2026-08-13 on CN — the only
parameter any route allowlists, and therefore the only one that reaches a cache key:
| request |
products |
canonical it declares |
?CN=Brand:BLACK%2BDECKER (the one %2B loc in the whole sitemap) |
53 |
self — keeps %2B |
?CN=Brand:BLACK+DECKER |
0 |
?CN=Brand:BLACK%20DECKER |
?CN=Brand:BLACK%20DECKER |
0 |
?CN=Brand:BLACK%20DECKER |
A raw + comes back canonicalized as %20 and returns that (empty) page, while %2B returns the
brand's 53 products — the origin read the raw + as a space. Under form-decoding + and %20
are the same character in every position, and %2B is the literal plus.
Confirmed from the other direction: all four spellings of a two-space multi-facet value return one
page (120 products) under one canonical — Color:Blue%20Silhouette:Bath%20Rugs,
Color:Blue+Silhouette:Bath+Rugs, and both mixings. So the two spellings are interchangeable in
separator and value position alike.
The origin's own canonicals mix both spellings inside a single URL — it emits + for a facet
separator and %20 inside a value, e.g. ?CN=Color:Blue+Silhouette:Bath%20Rugs. Unfolded, our key
and the origin's canonical for the same page therefore disagree by construction on every
multi-facet catalog URL. Folded, they agree. This was not part of the original argument for the fold
and is arguably the stronger one.
Resolution
cacheKey.plusIsSpace shipped in plugin v0.45.0 / browser v1.17.0 (#94): query-scoped, %2B
never folded, folded before the sort so two spellings of a multi-param query agree, mirrored in the
browser's cacheKey config, defaulted off so it ships inert.
Direction: %20 → +, the cheaper re-key. Counted over all six catalog sitemaps (38,013 locs,
2026-08-13):
| normalization |
catalog locs whose key changes |
%20 → + |
20,220 (53%) |
+ → %20 |
36,835 (97%) |
PDP routes drop their query entirely, so the whole product corpus is untouched either way.
This deployment is being set to true — paired PRs, to be deployed in one window:
- component: HarperFast/kohls-pr#69
- render fleet: HarperFast/render-service#72
Both sides must move together. The browser's mirrored canonicalizeUrl only ever compares two
URLs it normalized itself, so a comparison-only option (like cacheKey.decodeReserved) does not need
mirroring there. A fold is the opposite — it changes which URLs are the same key. If the plugin
folds %20→+ and the renderer does not, a job URL keyed under the folded spelling
(…Bath+Rugs) is compared against the page's canonical (…Bath%20Rugs), reads as a
canonical-variant, and gets suppressed — enabling the fold alone would retire the entire folded
catalog corpus.
Migration — this is the real cost
Re-keying orphans the cached page under the old key: 20,220 of 38,013 catalog locs × 2 device
variants ≈ 40,400 cached pages go cold and re-render — about 1.6h of fleet spare capacity at the
measured 71,289 renders/hr ceiling (the config sits at ~63%). PDPs, 94.7% of the corpus, do not move.
- Warm deliberately afterwards (
POST /sitemaps/<url> {revalidate: true}) rather than waiting for
the jittered natural cadence.
- The loser keys need a sweep, and this is the part that is not self-healing. A sitemap refresh
upserts the target under the new folded key and leaves the %20-keyed target in place, still on
its 6h schedule — ~20,220 × 2 orphan targets, ~161k renders/day of pure waste (~15% of fleet
output). Their renders are not incorrect (the renderer posts back the raw page URL, which the
plugin re-canonicalizes onto the live folded key, so each folded page simply renders twice per
interval), but they are wasted.
- The precise sweep predicate is "the stored url is not a fixed point of the current
canonicalizeUrl" — the general orphan test after any cache-key rule change, not a %20
regex. Worth building as a reusable operation rather than a one-off cleanup.
Interaction with #90
They compose, and #90 landed first — it is comparison-only and re-keys nothing. Once the fold is on,
a +-for-space URL is the canonical key, so it never reaches a canonical verdict at all and
canonical-variant volume for this class should fall to ~zero. That makes the reason slug the
monitor for whether the fold is doing its job.
Not in scope
- No blanket decode. 15% of catalog locs carry a structural escape —
%26 and %2F. Decoding
those reparses the URL (Product:Coats%20%26%20Jackets → an & that splits the query), and since
the canonical half is also the origin-fetch URL, it would mis-key and mis-fetch.
- No
%2B folding. Separator vs value position are different meanings; the BLACK+DECKER pair
above is the counterexample.
Note for the neighbouring sort rule
While editing the query-normalization block in both canonicalizeUrl copies: param sorting is
invariable for distinct keys (every major CDN ships it as a built-in cache-key normalization,
every mainstream server parses the query into a map, Google folds reorderings), and is a claim only
for repeated instances of one key — ?f=b&f=a sorts to ?f=a&f=b, which an origin honoring
repetition order would answer differently. Not a shape this deployment emits, so it needs no config,
just a comment so it is not re-derived. Repetition inside a value
(CN=…ChildAgeRange:Little+Kids+ChildAgeRange:Big+Kids) is untouched by a segment-level sort, which
is correct: that ordering is the site's grammar, not URL structure.
Refs #84, #90, #93, #94
Problem
Discovery mints a second cache key for pages we already have, purely by re-spelling a space inside a
faceted query value. Measured from a 30-minute
read_audit_logwindow onrender_service.Target(615 distinct discovery-created URLs, 93% catalog): 4.7% of discovered catalog URLs write a space
as
+—?CN=Color:Blue+Silhouette:Bath+Rugswhere the declared spelling isBath%20Rugs. Thatis ~1,300/day, and the origin serves both spellings the same page.
#90 stops them becoming immortal targets, by reading the page's canonical against the cache key: the
re-spelling is a
canonical-variantverdict and gets suppressed. But that is retirement, notidentity — it costs a render per URL, and since only 5 of 25 sampled canonical twins are in a
sitemap, usually neither spelling ends up prerendered and bots take the origin proxy for that page.
Collapsing the two spellings into one key is the better end state: the duplicate never exists, no
render is spent, and the crawler's
+-spelled URL serves the cached page instead of fallingthrough to the origin.
The standard a normalization has to meet
We serve on behalf of the origin. We fetch and render the representative
N(U)and then serve itsbytes for every
Uthat maps to it, so the requirement is not "these two URLs look equivalent" but:Every rule in
canonicalizeUrlis therefore a claim about the origin's URL semantics, and the claimsworth making are the ones settled at the parser boundary — where the decoder erases a distinction
before any application logic runs. Those hold for every URL, forever, and are established by one
discriminating probe per allowlisted query parameter, never by surveying pages. A claim that is
per-resource instead (does this path serve the same thing with a trailing slash? — #93) cannot be
established that way at all and should not be assumed.
This fold is a parser-boundary claim: the origin form-decodes the query. Under form-decoding
+and
%20are the same character before the application sees anything, so no page can distinguishthem — which is what makes the fold universally safe rather than safe-on-the-sample.
Why
+≡%20is safe to assert hereThe origin form-decodes its query. The discriminator, re-run 2026-08-13 on
CN— the onlyparameter any route allowlists, and therefore the only one that reaches a cache key:
?CN=Brand:BLACK%2BDECKER(the one%2Bloc in the whole sitemap)%2B?CN=Brand:BLACK+DECKER?CN=Brand:BLACK%20DECKER?CN=Brand:BLACK%20DECKER?CN=Brand:BLACK%20DECKERA raw
+comes back canonicalized as%20and returns that (empty) page, while%2Breturns thebrand's 53 products — the origin read the raw
+as a space. Under form-decoding+and%20are the same character in every position, and
%2Bis the literal plus.Confirmed from the other direction: all four spellings of a two-space multi-facet value return one
page (120 products) under one canonical —
Color:Blue%20Silhouette:Bath%20Rugs,Color:Blue+Silhouette:Bath+Rugs, and both mixings. So the two spellings are interchangeable inseparator and value position alike.
The origin's own canonicals mix both spellings inside a single URL — it emits
+for a facetseparator and
%20inside a value, e.g.?CN=Color:Blue+Silhouette:Bath%20Rugs. Unfolded, our keyand the origin's canonical for the same page therefore disagree by construction on every
multi-facet catalog URL. Folded, they agree. This was not part of the original argument for the fold
and is arguably the stronger one.
Resolution
cacheKey.plusIsSpaceshipped in plugin v0.45.0 / browser v1.17.0 (#94): query-scoped,%2Bnever folded, folded before the sort so two spellings of a multi-param query agree, mirrored in the
browser's
cacheKeyconfig, defaulted off so it ships inert.Direction:
%20→+, the cheaper re-key. Counted over all six catalog sitemaps (38,013 locs,2026-08-13):
%20→++→%20PDP routes drop their query entirely, so the whole product corpus is untouched either way.
This deployment is being set to
true— paired PRs, to be deployed in one window:Both sides must move together. The browser's mirrored
canonicalizeUrlonly ever compares twoURLs it normalized itself, so a comparison-only option (like
cacheKey.decodeReserved) does not needmirroring there. A fold is the opposite — it changes which URLs are the same key. If the plugin
folds
%20→+and the renderer does not, a job URL keyed under the folded spelling(
…Bath+Rugs) is compared against the page's canonical (…Bath%20Rugs), reads as acanonical-variant, and gets suppressed — enabling the fold alone would retire the entire foldedcatalog corpus.
Migration — this is the real cost
Re-keying orphans the cached page under the old key: 20,220 of 38,013 catalog locs × 2 device
variants ≈ 40,400 cached pages go cold and re-render — about 1.6h of fleet spare capacity at the
measured 71,289 renders/hr ceiling (the config sits at ~63%). PDPs, 94.7% of the corpus, do not move.
POST /sitemaps/<url> {revalidate: true}) rather than waiting forthe jittered natural cadence.
upserts the target under the new folded key and leaves the
%20-keyed target in place, still onits 6h schedule — ~20,220 × 2 orphan targets, ~161k renders/day of pure waste (~15% of fleet
output). Their renders are not incorrect (the renderer posts back the raw page URL, which the
plugin re-canonicalizes onto the live folded key, so each folded page simply renders twice per
interval), but they are wasted.
canonicalizeUrl" — the general orphan test after any cache-key rule change, not a%20regex. Worth building as a reusable operation rather than a one-off cleanup.
Interaction with #90
They compose, and #90 landed first — it is comparison-only and re-keys nothing. Once the fold is on,
a
+-for-space URL is the canonical key, so it never reaches a canonical verdict at all andcanonical-variantvolume for this class should fall to ~zero. That makes the reason slug themonitor for whether the fold is doing its job.
Not in scope
%26and%2F. Decodingthose reparses the URL (
Product:Coats%20%26%20Jackets→ an&that splits the query), and sincethe canonical half is also the origin-fetch URL, it would mis-key and mis-fetch.
%2Bfolding. Separator vs value position are different meanings; the BLACK+DECKER pairabove is the counterexample.
Note for the neighbouring sort rule
While editing the query-normalization block in both
canonicalizeUrlcopies: param sorting isinvariable for distinct keys (every major CDN ships it as a built-in cache-key normalization,
every mainstream server parses the query into a map, Google folds reorderings), and is a claim only
for repeated instances of one key —
?f=b&f=asorts to?f=a&f=b, which an origin honoringrepetition order would answer differently. Not a shape this deployment emits, so it needs no config,
just a comment so it is not re-derived. Repetition inside a value
(
CN=…ChildAgeRange:Little+Kids+ChildAgeRange:Big+Kids) is untouched by a segment-level sort, whichis correct: that ordering is the site's grammar, not URL structure.
Refs #84, #90, #93, #94