Skip to content

ADR-0002: Public/internal isolation is physical, not a route filter

ADR-0002 ACCEPTED 2026-09-20

The build brief is explicit: “Do not rely on hiding links… the public build must physically exclude internal source content from generated output whenever possible.” Starlight’s docsLoader() loads every file present under src/content/docs/ into the docs collection and routes it — there’s no supported per-request visibility filter at that layer, and even if there were, a filter is one bug away from a leak (wrong middleware order, a cached response, a search index built before the filter ran).

content/docs/public/** and content/docs/internal/** are the canonical source trees. scripts/stage-content.mjs <public|internal> copies ONLY the requested tree’s contents into src/content/docs/ before Astro is invoked; the other tree is never read. npm run build:public runs this staging step first, so an internal page literally does not exist as a file during that build — it can’t leak into HTML, the Pagefind search index, a source map, or an error stack trace, because the bytes were never on disk.

scripts/check-isolation.mjs (npm run test) enforces this in CI: it writes a throwaway internal-only page with a unique marker, builds public, asserts the marker is absent from dist/public (recursively, all file types), then builds internal and asserts the marker IS present there — so the test can’t pass by accident if staging silently no-ops.

  • Frontmatter visibility filter in the Starlight loader config — rejected per the “don’t rely on hiding” requirement above; also fragile against schema evolution and third-party integrations that might all-list the content directory (e.g. Pagefind’s indexer).
  • Two separate Starlight docs collections, one per audience, always both present — rejected: still leaves internal source bytes on disk during a public build, which is exactly the risk this ADR closes.
  • Robots.txt / noindex + route-level auth only — kept as a complement (internal pages do carry noindex, nofollow), never a substitute: noindex doesn’t stop a direct URL fetch, and Atlas’s public build shouldn’t even serve a URL that returns internal content, authenticated or not.
  • src/content/docs/ is generated, not committed (.gitignore) — anyone reading the repo must know to look in content/docs/{public,internal}/ for source, not src/content/docs/.
  • Every build script variant (dev, dev:public, dev:internal, build:public, build:internal) must remember to stage first; forgetting produces an empty or wrong-audience site rather than a leak, which is the safe failure direction.
  • A contributor can’t preview “both audiences at once” in one dev server — npm run dev:public and npm run dev:internal are separate processes. Judged an acceptable tradeoff for the security property.