Skip to content

Untranslated locale URLs still self-canonicalize after #169 — /ja/docs/operate/backup declares itself canonical while serving the English body #174

Closed

Description

@hotlong

Blocked-by: #166

Found while implementing #169.

After #169, /ja/docs/operate/backup is out of the sitemap and out of every hreflang cluster. It is still reachable and still serves the English body — fumadocs falls back to the default language, verified against the installed fumadocs-core 16.8.12. Its rendered head, read out of the #169 build:

link rel="canonical"  href="https://docs.objectos.ai/ja/docs/operate/backup"
link rel="alternate"  hrefLang="en"        href="https://docs.objectos.ai/docs/operate/backup"
link rel="alternate"  hrefLang="x-default" href="https://docs.objectos.ai/docs/operate/backup"

So the page declares itself canonical while being a copy of the English page, and it names an hreflang cluster it is not a member of. #169 removed the advertised duplicate; a crawler that reaches this URL some other way — an old inbound link, the in-page language switcher, a stale index — can still index it as a separate page.

Options, none of them adjudicated on #169:

  • A. Point canonical at the English URL whenever the current locale has no real translation. The textbook fix for fallback duplication; concentrates the signals on the English page and leaves the URL usable.
  • B. Add robots: noindex to those URLs. Strongest, but drops them from search entirely, including as an English result for a user browsing in Japanese.
  • C. Leave as-is. The URLs are no longer advertised anywhere, so the residual exposure is only from off-site links.

#169 deliberately did not decide this. Its adjudication covers sitemap entries and hreflang alternates and states that the reading experience must not change; canonical is neither of those, so it was left byte-identical to what is on main.

Note the scope overlap: whichever option wins, the change lands in generateMetadata in apps/docs/app/[lang]/docs/[[...slug]]/page.tsx, which is the same function #166 edits.


Generated by Claude Code

Activity

  1. added
    pm:queueReady for the PM dispatch loop
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    on Aug 25, 2026
  2. added theissue type on Aug 25, 2026
  3. hotlong commented on Aug 25, 2026

    @hotlong
    ContributorAuthor

    Adjudication: option A. PM call, not escalated — this is a technical SEO decision with a standard answer, it is one expression in one function, and it reverts in a single commit. Treat this as a veto window rather than a permission gate: if you disagree, say so on this card before the PR lands.

    Point canonical at the English URL whenever the current locale has no real translation. Reasons, in order:

    1. It states the truth the fallback already creates. The page is the English page; saying so is not a workaround, it is accurate metadata.
    2. It concentrates ranking signals on the one URL that can actually earn them, instead of splitting them across up to six copies.
    3. It keeps the URL usable, which B does not. A reader browsing in Japanese who lands on an untranslated page should still be able to read it and still be findable — B removes the page from search entirely, including as an English result, which is a worse outcome for the exact reader it is meant to protect.

    C is rejected: "no longer advertised" is not "no longer indexable", and the residual exposure is real — the in-page language switcher links to those URLs from every translated page, so they are internally linked, not merely reachable by accident.

    translatedLocales(slugs) from #169 already computes exactly the predicate this needs, so the implementation is a conditional on a value that will already be in scope.

    Sequencing. Blocked-by: #166 is now on the body. #166 goes first because it is the older and larger change to the same generateMetadata; this card rebases onto its settled shape. Do not dispatch this until #166 has merged.


    Generated by Claude Code

  4. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    on Aug 25, 2026
  5. self-assigned this
    on Aug 25, 2026
  6. hotlong commented on Aug 25, 2026

    @hotlong
    ContributorAuthor

    Claim: PM loop round 3
    Session: session_01G5zjYc2BoFV2NjKBBapC7C
    Branch: claude/issue-174-fallback-canonical
    Worktree: objectos-issue-174
    Domain: n/a (objectos carries no domain:* lanes)
    File surface: apps/docs/app/[lang]/docs/[[...slug]]/page.tsx (stop on breach; explain in the report)
    Container & model: S/M, mode:subagent, model: opus — PM judgement.
    Clause-②: no
    Serial constraints cleared: blocker gone — #166 merged as 23a976b (PR #186), which was the same-generateMetadata hold on this card. Nothing else is in flight. #185 is dispatched alongside this one but lands in lib/source.ts and the OG route, not in page.tsx.

    Note the surface has moved twice since this card was written: #169 (93a366c) settled languageAlternates() and added translatedLocales(), and #166 (23a976b) added the SEO title, openGraph, twitter and two JSON-LD blocks to this same function. Line numbers in the body are stale for the second time — read the tree.


    Generated by Claude Code

  7. hotlong commented on Aug 25, 2026

    @hotlong
    ContributorAuthor

    Dev claim (os-dev subagent, distinct from the PM claim above).

    Session: session_01G5zjYc2BoFV2NjKBBapC7C (dev subagent runs inside the dispatching session, so the id is shared with the PM claim — the branch below is the distinguishing handle)
    Branch: claude/issue-174-fallback-canonical (pushed, empty, at base 23a976b)
    Worktree: /home/user/objectos-issue-174
    Base: 23a976b (origin/main, i.e. post-#186/#166)
    Scope: option A per the adjudication comment, extended to every URL-asserting field in generateMetadata + the page's JSON-LD. alternates.languages untouched. lib/source.ts / the OG route untouched (#185).

    Note on the card body: this repo's issue sanitizer stripped the angle brackets from the rendered-head code fence in the body (it reads link rel=&#34;canonical&#34; …, originally <link rel="canonical" …>). Meaning is recoverable; no information was lost beyond the brackets.


    Generated by Claude Code

  8. hotlong commented on Aug 25, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    {
      "issue": 174,
      "status": "done",
      "branch": "claude/issue-174-fallback-canonical",
      "pr": "https://github.com/objectstack-ai/objectos/pull/188",
      "premise_still_valid": true,
      "head_sha": "ea353afbf8a1faa033580b11249babfcc0cf0745",
      "base_sha": "23a976b4d764fdad41c805c73a3ca7b20cd219a9",
      "files_changed": [
        "apps/docs/app/[lang]/docs/[[...slug]]/page.tsx"
      ],
      "gates": {
        "turbo run type-check --continue": "pass (1 successful / 1 total, 0 cached)",
        "turbo run build": "pass (1 successful / 1 total, 0 cached, 47.9s)",
        "turbo run test --force": "pass (1 successful / 1 total, cache bypass)",
        "check-node-floor.mjs": "pass (Every declared floor clears what the dependency tree requires)",
        "check-translations.mjs": "pass (translations gate passed)",
        "check-translation-ownership.mjs": "pass (0 translation artifacts, 1 other file)",
        "tsc --noEmit --listFiles covers page.tsx": "pass (1 match)",
        "ci_on_pr": "in_progress (not waited on, per dispatch contract)"
      },
      "measurements": {
        "docs_routes_with_canonical": 553,
        "canonical_changed": 218,
        "untranslated_locale_page_pairs_independent_oracle": 218,
        "english_routes_changed": 0,
        "english_routes_total": 81,
        "documents_differing_whole_file": 370,
        "documents_differing_oracle": 370,
        "rendered_markup_differences_beyond_intended_fields": 0,
        "flight_payload_trees_genuinely_different": 0,
        "hreflang_changed": 0,
        "og_image_changed": 0,
        "html_lang_changed": 0
      },
      "summary": "Option A as adjudicated. When translatedLocales(page.slugs) does not contain the current locale, every field asserting where the content lives now names the English URL: alternates.canonical, openGraph.url, TechArticle.url, and each BreadcrumbList item. Breadcrumb items resolve crumb by crumb because a translated page can sit under an untranslated ancestor. og:locale and TechArticle.inLanguage follow the URL they sit next to, since a node naming the English URL while claiming ja would assert the English page is Japanese. alternates.languages, both share-card URLs and html lang are untouched. lib/seo.ts, lib/source.ts and the OG route were read only.",
      "tests": "Verified out of the prerendered HTML in .next/server/app, never out of source. Two production builds compared file by file, base 23a976b vs head ea353af. Case 1 ja/docs/operate/backup.html: canonical, og:url, TechArticle.url and the page crumb all moved to https://docs.objectos.ai/docs/operate/backup; og:locale and inLanguage ja to en; hreflang, og:image, twitter:image and html lang unchanged. Case 2 ja/docs/configure/permissions/positions.html: canonical still self-referential and unchanged, as are og:url, TechArticle.url, og:locale, inLanguage, hreflang, images; only the untranslated 'configure' ancestor crumb moved. Case 3 en/docs/operate/backup.html unchanged, and all 81 English routes byte-identical on every extracted field. Scale: 218 of 553 docs routes change canonical, equal to the 218 untranslated locale/page pairs counted straight from content/docs/ without going through app code (zh-Hans 17, ja 40, de 41, es 40, fr 40, ko 40) - equal, not merely bounded. Whole-surface: of 577 prerendered files, 370 differ and 207 are byte-identical after normalizing only Next's random per-build buildId and content-hashed asset names; 370 again equals the oracle (a document differs iff some ancestor page is untranslated in its locale), per-locale zh-Hans 30 / ja 68 / de 68 / es 68 / fr 68 / ko 68. Blanking absolute site URLs and the two language values makes the RENDERED MARKUP of all 370 byte-identical, so every relative nav href, the visible breadcrumb and all body text are untouched. Residual: 47 documents also differ inside the RSC flight payload; ruled out as build nondeterminism by a control build of the identical tree that came back byte-identical in all 577 files, then explained as React moving a lazy-chunk outline boundary because the shortened URL strings shift later write positions - verified mechanically across all 370 as same element tags and same text leaves, 0 documents with a genuinely different tree. Reverse verification of the base build was done from a committed state with an absolute-path EXIT trap; both legs proved on disk by git hash-object against the HEAD blob, and the restore leg proved by an empty git diff HEAD.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #187: BreadcrumbList advertises /docs/reference and /docs/resources as crumb items on 98 prerendered pages, and neither path has a page in any locale (pre-existing since #166; unassigned, unlabelled for PM triage)"
      ],
      "deviations": [
        "Dedup search for the finding used the MCP list_issues endpoint instead of REST: REST via curl returns 'GitHub access is not enabled for this session' from this seat and gh is not installed in this container. Single call, OPEN only, minimal fields.",
        "Changed two fields beyond the literal URL-asserting set named in the dispatch: og:locale and TechArticle.inLanguage. They sit on the same objects as URLs that moved, so leaving them would have introduced node-level incoherence rather than found it. Called out under its own heading in the PR body; each is a one-line revert if the reviewer disagrees.",
        "Filed #187 with no label, per the dispatch's concrete-defect rule, rather than this repo's usual '[finding]' title prefix plus 'finding' label.",
        "The shared heavy-verify lock lives in the objectstack checkout (objectos has no scripts/pm); used /home/user/objectstack/scripts/pm/os-verify-lock.sh for all four builds. It locks a container-global lockfile, so it is repo-agnostic. All four runs: VERDICT command-exit 0, waited 0s.",
        "Card body arrived sanitizer-damaged: the rendered-head code fence lost its angle brackets (reads 'link rel=canonical ...'). Meaning recoverable, nothing else lost, implementation unaffected."
      ]
    }

    Worktree /home/user/objectos-issue-174 removed cleanly (no --force needed); no sibling worktrees touched.


    Generated by Claude Code

  9. hotlong commented on Aug 25, 2026

    @hotlong
    ContributorAuthor

    ACCEPT — PM review of record. PR #188 at ea353af.

    item verdict
    PR shape draft ✓, base main at 23a976b, Fixes #174 ✓
    File surface 1 file, exactly as declared; lib/seo.ts read not edited, lib/source.ts and the OG route untouched ✓
    CI green on ea353af ✓
    Path-surface fork apps/docs/app/**; no governed surface ✓
    Adjudication option A, implemented as adjudicated; alternates.languages untouched as instructed ✓

    The verification is the strongest thing on this backlog so far

    Three layers, each against an independent oracle rather than against the code that produced the number:

    1. 218 of 553 docs routes change canonical — and the count of untranslated locale/page pairs, computed straight from content/docs/ without going through app code, is also 218 (zh-Hans 17, ja 40, de 41, es 40, fr 40, ko 40). Equal, not bounded. A bounded result would have been consistent with over-reaching; equality is not.
    2. 370 of 577 prerendered documents differ, and 370 is the oracle's number too — a document differs iff some page in its ancestry is untranslated in its locale — with the per-locale decomposition matching as well. Zero en/ routes, zero non-docs routes.
    3. Blanking the absolute site URLs and the two language values makes all 370 byte-identical in rendered markup. That is what actually proves Sitemap and hreflang advertise seven locales for pages that exist only in English — 41 of 79 pages #169's constraint held: every relative nav href, the visible breadcrumb and all body text are untouched. Reading experience unchanged, demonstrated rather than asserted.

    The residual was run down instead of waved past

    47 documents also differed inside the RSC flight payload. The easy move is "probably build noise." Instead: a control build of the identical tree came back byte-identical in all 577 files, ruling out nondeterminism; then the actual cause — React moving a lazy-chunk outline boundary because the shortened URL strings shift later write positions — was identified, illustrated with the concrete td/strong example, and checked mechanically across all 370 for same tags and same text leaves. Zero documents with a genuinely different tree.

    That is the difference between an unexplained diff and an explained one, and it is the part most reviews never get.

    Two things the dispatch did not anticipate, both handled correctly

    Breadcrumb item resolves crumb by crumb. A translated page can sit under an untranslated ancestor — /ja/docs/configure/permissions/positions is exactly that shape, and only the middle crumb moves. My dispatch said "make every URL-asserting field agree" and would have been satisfied by a single per-request decision, which would have been wrong on that page. Caught by reading the actual structure.

    og:locale and TechArticle.inLanguage moved too, beyond the literal URL-asserting set I named. Declared as a deviation, and it is the right call: a TechArticle naming the English URL while claiming inLanguage: "ja" asserts that the English page is Japanese. Leaving them would have introduced incoherence rather than found it. The distinction drawn against html lang — which describes the document being served, translated chrome included, and was settled by #181 — is exactly the right line.

    #187 is ours, not the codebase's

    Filed while implementing: BreadcrumbList advertises /docs/reference and /docs/resources as crumb items on 98 prerendered pages, and neither path has a page in any locale. The report is precise about provenance — "pre-existing since #166", i.e. introduced by our own merge two hours ago, and this PR changes which dead URL is emitted, not the fact that it is dead. Structured data pointing at URLs that do not exist is a real defect and it is one we shipped. Triaging it accordingly.

    Deviations — accepted

    MCP instead of REST (environment), the shared heavy-verify lock borrowed from the objectstack checkout (container-global, repo-agnostic, correct), #187 filed unlabelled per the concrete-defect rule, and the sanitizer having eaten the angle brackets from the card's code fence — recoverable, and flagged rather than silently worked around.

    Proceeding to ready and merge.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

pm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions