Skip to content

DOC-6951 Publish redirects so a moved page is not read as a deletion - #3771

Open
andy-stark-redis wants to merge 2 commits into
mainfrom
DOC-6951-publish-redirects
Open

DOC-6951 Publish redirects so a moved page is not read as a deletion#3771
andy-stark-redis wants to merge 2 commits into
mainfrom
DOC-6951-publish-redirects

Conversation

@andy-stark-redis

@andy-stark-redis andy-stark-redis commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Makes a moved page resolvable instead of looking deleted — item D of DOC-6951. Based on main, independent of #3767/#3769/#3770.

The problem

Hugo emits alias stubs for the HTML output format only. So a moved page's old URL serves a 200 meta-refresh page, while its /index.json and /index.html.md both 404.

Our own ai-agent-resources page tells consumers to find a page's JSON by appending /index.json to its URL. Following that instruction on a page that moved returns 404 — so a move is indistinguishable from a deletion, for every move, including the ~90% we now alias correctly. #3769 fixed alias coverage; it could not fix this.

Two mechanisms

A record at the old URL. page_type: "moved", carrying url and moved_to and nothing else:

{"schema_version": 2, "page_type": "moved",
 "id": "data-types/bitmaps",
 "url": "https://redis.io/docs/latest/data-types/bitmaps/",
 "moved_to": "https://redis.io/docs/latest/develop/data-types/strings/bitmaps/"}

(That's a real one from the build — and one of the two moves the applied AI team's assessment originally cited.)

A map of all of them at /redirects.json, rendered from .Aliases — the same data Hugo uses for its own stubs, so map and site cannot drift and there's no generated file to keep in step.

The map's design changed once I counted the keys

The obvious version — one entry per declaration — published duplicate keys, 29 of them naming different targets, because two pages can declare the same alias. Hugo settles that by writing one stub and picking a winner arbitrarily, so publishing either target would hand consumers an answer the site doesn't honour.

So redirects holds only keys with exactly one target (791), and contested keys go in ambiguous with every candidate listed (29). Tombstones are written only for unambiguous keys, for the same reason. That's the same report-don't-guess principle as the scanner's collision bucket in #3767 — this is the fourth place the duplicate-alias problem has surfaced.

Tombstones stay out of docs.ndjson

They share the index.json name, so the feed's rglob would have swept in ~1,000 pointer records against 2,600 real ones, making any count of the corpus ambiguous — precisely the confusion DOC-6939 spent its time reconciling. generate_ndjson.py filters on page_type instead.

schema_version → 2

page_type: "moved" is a new value and moved_to a new field, so this is a shape change by the definition the AI team proposed and we adopted. Refusing to bump on the first real change would teach them the field is inert.

Page records also gain aliases, declared on both transform interfaces rather than riding on a spread, per the constraint from the schema_version work. It doesn't disturb content_hash, which covers summary, sections and examples only.

Verification — full build, real pipeline order

Result
tombstones written 774 from 791 map entries (8 no stub, 9 already held a real record)
duplicate keys in redirects 0
distinct keys accounted for 791 + 29 = 820, matching an independent count
feed records before / after tombstones 5,733 / 5,733
moved records leaking into the feed 0
transform re-run over the result 6,507 files, 0 transformed, all skipped

The no-overwrite guard did real work: 9 live pages left alone.

Review round (32d3f1a5b)

Two Bugbot findings, both correct, and both cases where the pass worked only because CI builds into a fresh tree:

Finding Fix
Skips updating existing tombstones the no-overwrite guard was too broad — it now reads page_type and refuses only for a real page's record, rewriting its own tombstones every run
Obsolete tombstones never removed the pass records the set it expects and sweeps any recognisable tombstone the map no longer names

Both are invisible in production, which is precisely why they were worth fixing: the script was correct by accident of its environment rather than by construction, and anyone running it locally twice would have got a wrong answer with no signal.

Verified against a real built tree: a re-run refreshes all 774 rather than skipping, a deliberately poisoned moved_to is corrected, a planted orphan is removed, a real page record is byte-identical afterwards, and the feed still holds 5,733 records with nothing leaking in.

Known gap

The published base_url depends on CI rewriting baseURL in config.toml with sed. That sed still matches after my edits — checked — but I haven't observed it against a deployed build.

Not included

D3data/page-moves.json for move dates and deleted pages, the two things Hugo cannot know. Deferred; say the word.

🤖 Generated with Claude Code

Hugo emits alias stubs for the HTML output format only. So a moved page's old URL
serves a 200 meta-refresh page while its /index.json and /index.html.md both 404 --
verified on three correctly-aliased moves and reproduced against Hugo 0.143.1. Our
own documentation tells consumers to find a page's JSON by appending /index.json to
its URL, so following that instruction on a page that moved returns 404 and the move
is indistinguishable from a deletion. That happens for every move, including the
ones we alias correctly, which is why the alias backfill did not fix it and this
does.

Two mechanisms. At the old URL there is now a record with page_type "moved" carrying
url and moved_to and nothing else, written after the build by walking the map Hugo
renders rather than by parsing stub HTML. And /redirects.json publishes the whole
map once, rendered from .Aliases -- the same data Hugo uses for the stubs, so the map
and the site's real behavior cannot drift and there is no generated file to keep in
step.

The map's design changed once measurement contradicted the obvious version. Emitting
one entry per declaration produced duplicate keys, 29 of them naming different
targets, because two pages can declare the same alias. Hugo settles that by writing
one stub and picking a winner arbitrarily, so publishing either target would have
handed consumers an answer the site does not honour. Keys with exactly one target now
go in `redirects` and contested ones in `ambiguous` with every candidate listed. 791
and 29 respectively in a full local build. Tombstones are only written for
unambiguous keys, for the same reason.

Tombstones stay out of docs.ndjson. They share the index.json name, so the feed's
rglob would have swept in about a thousand pointer records against 2,600 real ones
and made any count of the corpus ambiguous -- the exact confusion DOC-6939 spent its
time reconciling. generate_ndjson.py filters on page_type instead. Verified: the
record count is identical before and after the tombstone pass, and no moved record
reaches the feed.

page_type "moved" is a new value and moved_to a new field, so aiSchemaVersion goes to
2 by the definition the applied AI team proposed and we adopted. Refusing to bump on
the first real change would have taught them to ignore the field. The page records
also gain `aliases`, declared on both transform interfaces rather than left to ride
on a spread, per the constraint from the schema_version work. It does not disturb
content_hash, which covers summary, sections and examples only.

Verified end to end on a full build and the real pipeline order: 774 tombstones from
791 map entries with 8 skipped for having no stub and 9 for already holding a real
record, no duplicate keys in the map, the feed unchanged at 5,733 records, and a
re-run of the transform skipping all 6,507 files rather than rewriting a tombstone.

Learned: the naive map published contradictory data -- 29 keys with two targets each -- and only counting the keys showed it; the fix is to publish the contradiction rather than resolve it, because the site resolves it arbitrarily and any single answer would sometimes be wrong
Constraint: tombstones are written only for unambiguous keys, never over an existing index.json, and only where Hugo actually emitted a stub -- an alias on a draft or on a taken URL gets nothing, matching the site
Constraint: redirect tombstones must stay out of docs.ndjson, which is one record per documentation page; generate_ndjson.py filters page_type "moved"
Gaps: the published base_url depends on CI rewriting baseURL in config.toml with sed, which still matches; not observed against a deployed build
Ticket: DOC-6951
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at 964d6b4

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

DOC-6951

Comment thread build/write_redirect_tombstones.py
Comment thread build/write_redirect_tombstones.py Outdated
Two Bugbot findings, both correct, and both cases where the pass only worked because
CI happens to build into a fresh tree.

The no-overwrite guard was too broad. It existed to protect a real page's record,
which it must, but it also refused to rewrite the pass's own tombstones -- so on an
incremental build a tombstone kept a moved_to that had since changed. The guard now
asks whether the file it found is one of ours, by reading page_type, and refuses only
for anything else.

Nothing removed a tombstone either. An alias that leaves the map, or becomes ambiguous
and so moves into the candidate list, left its tombstone behind pointing somewhere the
site no longer redirects. The pass now records the set it expects and sweeps any
recognisable tombstone the map no longer names.

Both are invisible in production, because every CI build starts from an empty
directory. That is exactly why they were worth fixing: the script was correct by
accident of its environment rather than by construction, and the next person to run
it locally would have got a wrong answer with no signal.

Verified against a real built tree: a re-run refreshes all 774 rather than skipping
them, a deliberately poisoned moved_to is corrected, a planted orphan is removed, a
real page record is byte-identical afterwards, and the feed still holds 5,733 records
with no moved record leaking in.

Learned: "correct because CI starts clean" is not correct -- both defects here were invisible in production and would have produced silently wrong output for anyone running the script twice
Constraint: the guard protects real page records, not the pass's own tombstones; those are rewritten every run, and ones the map no longer names are deleted
Ticket: DOC-6951
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🧠 Redis Memory

Found 5 related items from repository history:

Memory updated at 32d3f1a

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 32d3f1a. Configure here.

{{- $ambiguous := slice -}}
{{- range $from, $targets := ($seen.Get "map" | default dict) -}}
{{- if eq (len $targets) 1 -}}
{{- $redirects = $redirects | append (dict "from" $from "to" (index $targets 0)) -}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Map lists unhonored alias routes

Medium Severity

redirects.json is built from every declared alias with a single target, but write_redirect_tombstones.py only writes tombstones where Hugo left an alias stub and skips paths with no index.html or with a real index.json. Those unambiguous redirects entries can still appear in the map while the old URL serves a live page or has no JSON, so bulk consumers can get a to URL that does not match from/index.json or the HTML route.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 32d3f1a. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant