Status: Proposed (2026-06-12, revised — simplified from the original directory/docSet design)
Deciders: ObjectStack Protocol Architects
Builds on: ADR-0003 (package + versioned releases), ADR-0016 (publish pipeline, manifest_json snapshot), ADR-0025 (artifact distribution, trust boundary), ADR-0033 (AI as primary author)
Consumers: @objectstack/spec (DocSchema, stack docs element), @objectstack/cli (collection, publish lint), @objectstack/console (doc rendering route), cloud control plane (registry rendering, grants), @objectstack/core (kernel registers doc as inert metadata)
Pilot: os-tianshun-mtc
A package today carries every behavioral fact about itself — objects, fields, validations, flows, permissions — but not one sentence of intent: what it is, how its users operate it, how its admins run it. That prose lives in ad-hoc repo trees and static sites that rot the moment metadata changes, and reaches customers through channels that duplicate the registry's versioning and access control.
This ADR makes documentation a metadata element, with the smallest possible shape:
- One flat directory. Every
.mdfile insrc/docs/(no subdirectories) compiles into onedocmetadata item, exactly like a view or a dashboard. There are no docSets, nometa.json, no ordering files, no directory taxonomy. - Filename = metadata name = link anchor = URL. A doc's identity is
its filename stem, namespace-prefixed like every other metadata name.
Docs reference each other with plain relative Markdown links
(
[guide](./crm_lead_guide.md)); because the tree is flat, link resolution is a basename lookup with zero path arithmetic, and a reference never breaks by "moving a file to another folder" — there is no other folder. - Frontend is one route. The console renders a doc at
/docs/<name>, rewriting*.mdlinks to that route. No help-center tree, no sidebar taxonomy, no navigation model in v1.
src/docs/ compiled into stack `docs: DocSchema[]`
├── crm_index.md what this package is
├── crm_lead_guide.md how to work leads ─┐ reference each
└── crm_admin_setup.md how to configure it ─┘ other by filename
- The manifest schema has a
descriptionstring and nothing else documentation-shaped. ADR-0025 defines how code and dependencies travel; nothing defines how intent travels. - Pure-element packages publish as a single self-contained JSON
(
dist/objectstack.json→sys_package_version.manifest_json, ADR-0016). Anything added to the stack therefore reaches the registry with zero registry schema changes, andsys_package_grantauthorization applies automatically — granting a package grants its docs. - The first revision of this ADR designed a directory taxonomy (closed
docSet enum, per-directory
meta.jsonordering files, four lifecycle trees). Review verdict: that is a site design, not a metadata design. Every other metadata element is a flat, namespace-keyed collection; docs gain nothing from being the one tree-shaped element, and the tree actively hurts the property docs need most — stable cross-references. A reference into a tree encodes a path; paths change when content is reorganized. A reference into a flat namespace encodes a name; names are forever. - Going forward AI writes most documentation (ADR-0033). Flat,
name-addressed items are also the shape AI authors best: create/update
one item, link by name — identical to how it already authors objects
and views (draft-gated via
saveMetaItem, same review pipeline).
- A spec'd
docmetadata element: schema, naming, collection rule, cross-reference rule, syntax boundary. - CLI collection (
os build) and publish lint (broken links, banned syntax). - One console rendering route, plus AI-assistant grounding (agents answer "how do I…" from the package's own docs — same JSON the kernel loads).
- Delivery documents (contract-bound, milestone-frozen) — convention
delivery/, outside the package. - Standalone product doc sites (docs.objectstack.ai) — those are
websites;
content/docs/remains their convention. - Internal engineering notes —
internal/, never ships. - Rich media: v1 is text-only (§3.4).
Directory taxonomy / docSets, meta.json ordering, help-center
navigation tree, CHANGELOG.md collection, per-package-type minimal-set
enforcement, binds contextual help, i18n sibling files, quality-gate
tooling (os docs lint|verify beyond publish lint), asset service.
Each is additive on top of flat name-addressed items; none is expensive
to retrofit, so none is mandatory now.
// packages/spec/src/system/doc.zod.ts
export const DocSchema = z.object({
name: z.string().regex(/^[a-z][a-z0-9_]*$/)
.describe('Unique doc name; MUST carry the package namespace prefix (enforced by build/publish lint)'),
label: z.string().optional()
.describe('Display title; defaults to the first `#` heading, then the name'),
description: z.string().optional()
.describe('One-line summary for listings (frontmatter `description:`); travels in the list response'),
content: z.string().describe('Raw Markdown (CommonMark + GFM)'),
});StackDefinition gains docs: z.array(DocSchema).optional(), beside
views / dashboards. The kernel registers metadata type doc as
inert data — no runtime behavior, no load-order participation.
Two hard requirements keep manuals out of hot paths: the kernel load
path must not parse or validate content beyond schema type-checking,
and the metadata list endpoint must support returning name + label
without content — docs are the one element whose payload grows
unbounded, and manifest-size pressure lands here first.
os build compiles every src/docs/*.md into one DocSchema item:
-
name= filename stem. The stem MUST match^[a-z][a-z0-9_]*$and carry the package namespace prefix (crm_lead_guide.md→crm_lead_guide). A package that ships docs MUST declaremanifest.namespace— the natural extension of "defining objects requires a namespace".The prefix requirement looks like the object rule but sits at a different layer, and the enforcement posture follows from that. Object names are prefix-validated in the kernel because they are physical: object name = table name in a shared database; a collision is a data-layer disaster. Doc names have no physical footprint; their uniqueness need is logical — the metadata registry key is
org/type/namewith no package coordinate, so two packages shipping the same bare name silently overwrite each other (last-write-wins), and the console route/docs/<name>plus all cross-references key on the name. Hence: enforced as build/publish lint, not kernel-level rejection.docis a new type with zero legacy, so day-one lint costs nothing — the same reasoning as the image ban. -
label= frontmattertitle:if present, else first#heading. -
description= frontmatterdescription:if present (optional one-line summary; the docs portal renders it under the title). -
content= the file body (frontmatter stripped). -
Subdirectories under
src/docs/are a build error, not silently flattened — flatness is the contract that keeps references stable.
TS-first stacks may also pass docs: [...] inline in defineStack();
the md-file convention is sugar over the same array.
Docs link to each other with plain relative Markdown links:
See the [lead guide](./crm_lead_guide.md#qualification) for details.- Resolution rule: strip
./and.md→ doc name. Because names are globally unique, this resolves the same way everywhere — inside the authoring repo (editors/GitHub preview work natively), in the console, in the registry, even across packages (a doc may link to a dependency's doc by its name). - Publish lint resolves every relative
.mdlink against the package's docs plus its dependencies' docs; unresolvable links block publish. This check is trivial because the namespace is flat. - Cross-package links are checked at publish against the dependency versions present at publish time; a dependency may later remove a doc. Renderers degrade an unresolvable link to a "doc not found" notice — link integrity must NOT couple into install-time dependency resolution (that would be over-design for prose).
- Semantic links to metadata (
object://crm_lead) remain reserved syntax renderers may resolve later; v1 lint only requires they parse.
- Pure Markdown (CommonMark + GFM). MDX is forbidden: MDX is code, and rendering publisher-supplied code inside the platform origin crosses the ADR-0025 trust boundary. Markdown is data; a sanitizing pipeline renders third-party packages safely.
- No image references in v1 (publish lint rejects
). Binaries bloat artifacts; external URLs break version immutability and leak customer data to unmanaged hosts. v2 introduces a content-addressed platform asset service; enforcing the ban from day one means zero legacy cleanup.
Anything reconstructible from the manifest — field dictionaries, option lists, validation conditions, permission matrices — is a metadata view: the platform renders it live. It must not exist as authored prose or generated markdown committed to the repo (a cache in git, with a cache's lifecycle). The one exception is a frozen export (e.g. a confirmation PDF) bound to a released version: produced once, never maintained, so it cannot rot.
- v1 needs no registry change: docs ride
manifest_json; package visibility andsys_package_grantapply automatically. - Two address spaces, two coordinate systems: inside an instance,
namespace uniqueness is guaranteed, so routes are single-coordinate
(
/docs/<name>); in the registry/cloud, namespace uniqueness does not hold across publishers and versions coexist, so registry surfaces address docs with full coordinates (package id + version + name). Neither weakens the other. - The standard metadata read API serves a doc by name
(
type='doc', name='crm_lead_guide') like any other metadata item. - Console: one route,
/docs/<name>— fetch, sanitize, render Markdown, rewrite*.mdlinks to/docs/<target>(anchors preserved). Any surface that wants to point at documentation (registry package page, app navigation, an agent's answer) does so with that URL. Navigation/sidebar/search are later, additive concerns. - AI assistant grounding: in-app agents load the package's docs as context to answer "how do I…" — likely the highest-frequency consumer and a direct payoff of docs-in-manifest.
| Alternative | Verdict |
|---|---|
Directory taxonomy: closed docSet enum + per-directory meta.json (the original revision of this ADR) |
A site design, not a metadata design. Trees make cross-references path-shaped and fragile; ordering files are structure-beside-content that AI must keep in sync. Flat name-addressed items match every other metadata element and make link lint trivial. Navigation, when needed, becomes derived structure (or an explicit later element) — not authored directory shape. |
Package-scoped doc names + /docs/<package>/<name> URLs |
Two coordinates per reference; cross-package links and "one URL per doc" both get harder. Namespace-prefixed names reuse the platform's existing uniqueness rule for free. |
| Static doc sites per project (Pages/R2 + auth) | Duplicates registry versioning, identity, and grants. Acceptable only as a transition channel. |
| Generated reference markdown in-repo | A cache in git; rots by construction (§3.5). Validated empirically on the pilot. |
| MDX / React components in docs | Code crosses the trust boundary; interactivity comes from renderer-side directives and metadata views. |
content/docs/ as the package-docs home |
content/ is the standalone-website convention; package docs are package source → src/docs/. |
| Phase | Scope | Depends on |
|---|---|---|
| P0 (no platform change) | Repo convention (src/docs/*.md flat, naming rule, link style) on pilot projects |
this ADR |
| P1 (framework) | DocSchema in spec; os build collection; publish lint (naming, flatness, link resolution, MDX/image ban); kernel doc type |
P0 |
| P2 (console/cloud) | /docs/<name> rendering route; registry package page renders the package's *_index doc; AI-assistant grounding |
P1 |
| P3 (enrichment, all additive) | navigation/search, binds contextual help, i18n, quality gates, asset service + images |
P2 |
P0 conventions are isomorphic to the P1 schema by construction: when the compiler lands, pilot content migrates with zero edits.
Tags, categories, and ordering are navigation-model concerns and stay in P3 by design — adding them before there is enough doc volume to need filtering buys an i18n-bearing protocol field with no payoff. When they land, design the discovery surface (tags + category + search + cross-package aggregation) as one thing, not field-by-field. The agreed shape for tags:
- A tag is a stable key, never a display string. Display always resolves
through the platform's existing label-key → i18n mechanism, exactly like
every other label. Free-form display strings fail twice: cross-package
fragmentation (
setupvsgetting-startedvsquickstart) and no i18n owner. Keying fixes both. - Layered vocabulary, not closed-vs-open. The protocol blesses a small
core of cross-cutting "purpose" tags (
getting-started,guide,reference,tutorial,api,migration,troubleshooting) — central i18n, eligible for dedicated UI. Packages extend with namespace-prefixed tags (crm_*, same rule as doc names) for domain topics, shipping their translations in the package i18n bundle (same path as object/field labels). - Pure closed enum is too rigid (packages can't express domain topics); pure open free-form is too fragmented and has no i18n home. The layered key-based model is the resolution.
The current addition — description — deliberately stops short of this: it is
a per-doc summary, not a taxonomy, so it carries no i18n-keying or
cross-package-coherence burden.
Status: Accepted (2026-06-15) — concretizes the P3 "navigation" row.
This is the navigation model §4 deferred; it is recorded here rather than as a
new ADR because it is purely additive on top of the flat doc element and
changes none of P0–P2.
§1 and §4 reject "directory taxonomy: closed docSet enum + per-directory
meta.json" as a site design, not a metadata design. The book element
must be read against that verdict, because it reintroduces ordering and
grouping — the two things that rejection touched. It is not the rejected
design, and the distinction is the whole point:
- What §4 rejected was references encoding paths. A tree of folders makes
a cross-reference path-shaped (
../guides/x.md); reorganizing content breaks links. That is the failure mode docs most need to avoid. bookkeeps content flat and name-addressed. Docs still live in one flatsrc/docs/, still reference each other by bare name (§3.3), and those references stay valid forever regardless of how any book is arranged. The ordering/grouping structure lives in a separate explicit artifact that references docs by name — never by path.
Flat content namespace + one explicit nav artifact = hierarchy without path coupling. This is exactly the property §4 wanted and the rejected tree-of-folders design could not deliver. Ordering is no longer a per-folder file beside content that must track file moves; it is a first-class element (
book) that names stable things.
So the rejected design coupled navigation into the content layout; book
separates navigation from it. Same goal, opposite mechanism.
A book is the spine of a table of contents: an ordered set of groups
(sections) plus identity and access. It deliberately does not store its
members. Membership — which doc sits in which group — is derived from a rule
on each group (plus an optional per-doc ordering), never held in a central
array. This is the load-bearing decision of the whole design, and §6.2.1
explains why it is the property that keeps AI authoring safe.
A package ships zero or more books (CRM may ship User Guide, Admin
Guide, API Reference). A book never owns content: a doc may surface in two
books or none.
// packages/spec/src/system/book.zod.ts — the SPINE only
export const BookGroupSchema = z.object({
key: z.string().regex(/^[a-z][a-z0-9_]*$/), // stable group key (overrides / deep links)
label: z.string(), // section title — first-class, i18n-homed
translations: z.record(z.string(), z.object({ label: z.string() })).optional(),
order: z.number().optional(), // order of THIS group within the book
// membership is DERIVED, not stored:
include: z.union([
z.string(), // glob over doc names, e.g. "crm_guide_*"
z.object({ tag: z.string() }), // or by doc tag (§5 vocabulary)
]).optional(),
package: z.string().optional(), // scope the rule to a package (default: this one; cross-package via ADR-0048)
pages: z.array(BookNodeSchema).optional(), // OPTIONAL explicit override — hand-pin a curated order; wins over `include`
});
export const BookSchema = z.object({
name: z.string().regex(/^[a-z][a-z0-9_]*$/), // namespace-prefixed, like every metadata name
label: z.string().optional(),
description: z.string().optional(),
translations: z.record(z.string(), z.object({
label: z.string().optional(), description: z.string().optional(),
})).optional(),
slug: z.string().optional(), // portal URL segment; defaults to name sans prefix
icon: z.string().optional(),
order: z.number().optional(), // orders books within the portal
audience: z.union([ // §6.7 — a reference into the permission model, not a bespoke enum
z.literal('org'), // default — inherits the package grant (§3.6)
z.literal('public'), // ≡ the data-layer `guest` profile (anonymous)
z.object({ profile: z.string() }), // role-gated, e.g. { profile: 'admin' }
]).default('org'),
groups: z.array(BookGroupSchema), // the spine: ordered sections. Two levels total.
});
// BookNodeSchema is used ONLY inside an explicit `pages` override:
export const BookNodeSchema = z.union([
z.string(), // a doc name
z.literal('---'), // separator
z.literal('...'), // rest: docs matched by no explicit entry
z.object({ doc: z.string().optional(), href: z.string().optional(),
label: z.string().optional(), badge: z.string().optional(), icon: z.string().optional() }),
]);
// The ONLY per-doc additions — both optional, both scalar (field-merge-friendly, §6.2.1):
// DocSchema += { order?: number, group?: string }
// `order` sorts a doc within its group; `group` is an explicit placement used
// only when no rule expresses it. Naming-by-convention (`crm_guide_*`) usually
// makes both unnecessary.The earlier draft of this section stored the whole tree in one
groups[].pages[] array. That array conflated three concerns with very
different change profiles:
| concern | cardinality | changes | who edits |
|---|---|---|---|
| group definitions (label / order / i18n) | low | almost never | a human, curating |
| membership (which doc → which group) | high | every new doc | the AI |
| in-group order | high | often | AI / human |
Only the first deserves central storage. Putting the high-cardinality, AI-churned membership in a central array breaks the two properties that matter most here:
- AI safety — the decisive reason. ADR-0033 makes AI the primary author,
and §1 already chose flat docs because AI authors best by creating one item
and linking by name, not by read-modify-writing a shared structure. A
central
pagesarray forces exactly that read-modify-write on every doc the AI adds: a stale or concurrent edit silently drops or reorders siblings — the failure mode is invisible and lands in navigation, where it is least noticed. A derived spine removes the write entirely: the AI creates a doc named to match a rule (crm_guide_lead→ caught byinclude: "crm_guide_*") and it files itself. Create-and-forget — the identical shape the AI already uses for objects and views, and the whole reason §1 made docs flat. - Overlay safety. Overlay is RFC 7396 JSON Merge Patch, which treats
arrays atomically (whole-array replace, never element merge). A runtime
nav overlay on
pageswould therefore shadow docs a later package version adds — the customer's array wins under thekeep-customstrategy and the new docs silently vanish from their navigation. With membership derived and only a small, rarely-touched spine stored, the volatile part never enters an array; per-docorderis a scalar that merges cleanly three-way.
This is not the "scatter the category onto every doc" model an earlier turn
rejected. Categories keep a single home — the spine defines their label / order
/ i18n and is the one diffable artifact; only membership is distributed, and
usually it is not even stored, it is matched. That is the empirically
dominant pattern for content at scale (Docusaurus _category_.json + per-doc
sidebar_position; WordPress central categories + per-post assignment). The
central-nav-file pattern (Mintlify docs.json) only holds for small,
hand-curated sites — not AI-scale authoring.
Locked design choices:
- Two levels (groups → docs). Going deeper reintroduces the path-coupling §4 fought; when two are not enough, ship another book (an effective third tier via tabs, no recursion).
- Membership derived by rule (
includeglob/tag), with an optional per-docorderfor sorting and an optional explicitpagesoverride per group for hand-pinned curation. Default rule-based (AI scale); explicit only where a human curates a fixed reading path. ...rest inside an override sweeps unmatched docs so nothing is dropped.pagesnodes may bestring | object(badge / icon / externalhref/---) without a v2 break; the bare string is the common case. A navlabelis an optional override — the doc's ownlabelstays the title authority.
§5's P3 note commits to a layered tag vocabulary. Tags and book-groups coexist and must not be conflated:
book group |
tag (§5 P3 note) | |
|---|---|---|
| Purpose | explicit ordered navigation (the sidebar) | faceted filtering / search across docs |
| Shape | positional, hierarchical-by-book | flat, cross-cutting, keyed |
| Scope | within one book | global, cross-package |
| Authority | the book artifact | the doc's own tag list |
A doc can sit under the Getting Started group of crm_user_guide and
carry the cross-package getting-started tag. The tag powers a global
"getting started across all your packages" view; the group powers this book's
sidebar. Neither subsumes the other — even when a group selects by a tag
(include: { tag: 'getting-started' }), the tag remains the doc's property
(a facet) while the group remains the book's section (navigation). The rule
borrows the facet; it does not turn the section into the tag.
- The package is its own implicit book. There is always exactly one book
per package by default — a synthetic book keyed by the
packageId(label andaudienceinherited from the package), whose single group lists all the package's docs via...(alphabetical)._packageIdis already stamped on every doc, so this costs nothing. Authoring abookrefines that default; authoring several suppresses the synthetic one, and any doc left unplaced rolls up under a synthetic Uncategorized group shown after them — no doc is ever unreachable.app-todo's two docs therefore need zero config and still render as one clean package-book. The model has no "flat vs book" fork: the portal always renders books; "flat" is just the synthetic book. - One authoring form:
*.book.ts. Abookis structured navigation metadata, so it is authored like every other element —defineBook({...}), type-checked, collected byos build. There is deliberately nometa.json(nor any JSON sidecar): markdown files carry prose content,.book.tscarries structure, and that is the whole split. A JSON form would be a third authoring dialect that serves no one — developers get types from TS, and non-developers rearrange in Studio (next bullet), never by hand-writing JSON. The original TL;DR's "nometa.json" therefore still holds: navigation arrives as a typed element, not an ordering file. - The file-defined book is a seed, not the source of truth. The
*.book.tsitem enters as a package-provenance row; runtime edits in Studio override it as a user-provenance overlay row (same posture as ADR-0033 draft/overlay). Because the spine is small and rarely touched (§6.2.1) the overlay is correspondingly small, and a redeploy's 3-way field merge brings in new package sections without clobbering a diverged runtime overlay. bookopens overlay;docdoes not. ADR-0046 keepsdocsupportsOverlay: false(§3.1, content is inert).bookis the deliberate exception: navigation is the one docs surface a non-developer is expected to rearrange in the UI, so for a low-code platform runtime-editable nav is a core capability, not a nicety.bookis a render-time type likeview/dashboard, so it joins their override whitelist: the registry entry setsallowOrgOverride: true(the real flag, peroverlay-precedence),allowRuntimeCreate: true,supportsVersioning: false,loadOrder: 99,domain: 'system'; a package may still ship a book with_lock: 'no-overlay'to pin a curated manual.- Membership stays current under overlay. Because membership is derived
(§6.2.1), the rendered tree always reflects the docs that exist now — a doc
a package adds appears immediately via its group rule, even when a runtime
spine overlay exists. A doc matched by no rule and pinned by no override falls
to a synthetic Uncategorized group (never dropped); publish lint reports it.
A
pagesoverride naming a missing doc degrades to a "not found" notice — link integrity never blocks build (as §3.3). - Guardrail — a
bookis a ToC, never a micro-site. No theme, no CSS, no landing-page/hero fields, no per-book hosting. Those would re-import exactly the "site, not metadata" weight §4 rejected. A true end-user documentation micro-site, if ever wanted, is a separate, heavier, later element — not an accretion ontobook.
doc a page (§3, exists) inert content, flat, name-addressed
⊂ book a spine (this section) ordered groups; membership DERIVED, not stored
⊂ library the /docs portal (rendering surface) cross-package aggregation — NOT a per-package type
- A package has many
docand manybook. - The portal ("library") orders books by
book.orderand by the package relationship; the top-level grouping of packages (Core / Solutions / Apps) is a portal-level taxonomy, deferred and out of scope here — it is not solved by a single package weight and must not be smuggled intobook. - Why
bookand notsite/docSet/manual/guide:siteover-claims (theme/domain/landing) and would invite the scope creep §6.4 forbids;manual/guideare too narrow (an API reference is neither) andguidecollides with single-doc connotation;bookis neutral, short, reads well as "a package has multiple books", and sits cleanly besideview/page/dashboard.libraryis reserved for the portal if it ever needs a name.
| Phase | Scope | Depends on |
|---|---|---|
| P3a (spec + build) | BookSchema (spine); additive doc.order / doc.group; defineBook(); the include rule + ... resolver; orphan lint |
P2 |
| P3b (kernel + REST) | register book (allowOrgOverride: true); GET /meta/book (sorted by order); read-layer audience gating on /meta/doc (§6.7); package-scoped resolution (ADR-0048) |
P3a |
| P3c (console + Studio) | portal resolves the derived tree (implicit package-book fallback); Studio drag-edit writes back doc.order / group rules (overlay); anonymous library via apps/docs ISR (§6.7) |
P3b |
| P3d (deferred) | portal-level package taxonomy; book-level search; tags (§5) | P3c |
The §5 tag note is unchanged and still deferred; book lands the navigation
half of P3 first because it is the half packages need to ship a readable manual,
and it is independent of the cross-package tag vocabulary.
The largest category difference between app and docs: an app is an
authenticated runtime over data; documentation is read-only content that
is often meant to be public. That difference must land in two places — an
access property on the publication unit, and a distinct frontend surface.
Access is a book.audience, expressed in the existing permission model —
not a bespoke enum. §3.6 today couples docs entirely to the package grant
("granting a package grants its docs"). That is right for the common case and
wrong to assume universally: most package docs describe a tenant's configured
app — field names, flows, intent — and anonymous exposure of those is a
configuration leak. The platform already has a mature audience vocabulary on
the data side (OWD public_read, sharing rules with a guest recipient,
profiles, permission sets); metadata has none. Rather than invent a parallel
two-value flag, book.audience is the first metadata access concept and is
shaped as a reference into that existing model:
audiencedefaults toorg— inherits the package grant exactly as §3.6.'public'≡ the data-layerguestprofile: anonymous, indexable.{ profile: 'admin' }gates a book to a profile / permission set — so Admin Guide visible only to admins is a day-one capability (Phase D governance), not a v2 retrofit. The enum never grows; richer gating is just a richer profile reference.- A
doc's effective audience is the union over the books that reference it. Audience lives on the publication unit, never per-doc — per-doc gating would leave readers with half-broken navigation. A doc reachable only fromorgbooks stays gated; a doc reachable from onepublicbook is public.
This relaxes §3.6 (grant-inheritance becomes the default, not the only mode);
it does not reopen the §2 non-goal that excluded standalone product doc
sites — a public book is still registry metadata (doc/book), sanitized
and rendered by the platform, not a separately-hosted static website.
Gating is enforced at the read layer, not just the UI. Ground truth:
/meta/doc is already anonymous-reachable (the gate is the optional global
requireAuth or the SPA's ProtectedRoute, not the handler). So a public
portal that mixes public and gated books must apply audience inside the
/meta/doc and /meta/book read path — gating only the UI would let a gated
doc leak straight from REST.
Two frontend surfaces, addressed differently. The URL must distinguish the authenticated app from the docs portal — and not only for the auth gate:
| app surface | docs portal ("library") | |
|---|---|---|
| Auth default | required | anonymous-first, login demanded per org book |
| Shell | app chrome (nav, user menu, org context) | standalone read-only shell |
| Indexing | noindex, behind auth | crawlable: canonical / OG / sitemap |
| Tenant resolution | from session | from host (resolveByHostname) |
The deciding reasons are the bottom two rows, not the auth gate:
- SEO: public docs must be crawlable with clean canonical URLs; app routes
are post-auth and
noindex. They cannot share one prefix cleanly. - Tenant from host, not path: the tenant is carried by the hostname
(
<tenant>.docs.<host>), resolved by the existingresolveByHostnameregistry the rest-server already uses for virtual-host tenancy — so the path needs no tenant coordinate. A doc's canonical URL is/<lang>/<doc>(the locale segment serves SEO/hreflang, mirroringapps/docs). Thebooksupplies breadcrumb/sidebar context, never the canonical path, so a doc reachable from two books still has exactly one canonical URL. Version is also implied by the host (the tenant's installed package version), so an anonymous reader needs no version coordinate either.
One portal, per-book auth — not two URL trees. The recommended shape is a
single docs portal ("library") that is anonymous-first and escalates to login
only when a requested book's audience demands it (org or a profile). A doc
therefore has one
canonical, shareable URL; public and gated books coexist on the same portal
(the Stripe/GitBook pattern). Splitting into separate public-vs-private URL
trees would give the same content two URLs and is rejected. The app stays the
separate authenticated runtime surface; the split that matters is app vs
library portal, not public-vs-private within docs.
The portal reuses apps/docs (Fumadocs + ISR), it is not greenfield SSR.
Ground truth: the console SPA cannot do SEO, and apps/docs is build-time
static from content/docs/ — neither renders runtime, host-resolved tenant
metadata. Rather than build a new SSR surface, the recommended path is to point
the existing Fumadocs + Next.js pipeline at the runtime book/doc API
via per-host ISR (incremental static regeneration): the spine maps directly
onto Fumadocs' tree, and canonical / OG / sitemap / hreflang come for free from
Next. This is materially less work than greenfield SSR and is the honest cost
of anonymous docs — significant, but reuse, not new infrastructure.
Security guardrail — anonymous renders inert prose only. An anonymously
served doc renders Markdown content and nothing live. Any "live metadata"
embed — semantic links (object://crm_lead, §3.3), inline metadata views /
field dictionaries / derived content (§3.5) — must not resolve for an
anonymous request; it degrades to a hidden/"sign in to view" placeholder.
Docs carry no data, so prose itself is safe to expose; the only leak vector is
live-metadata resolution, and closing it for anonymous requests is the one hard
rule. Phasing: audience + the library portal's anonymous path land in
P3b/P3c; until then all docs remain gated as
§3.6 specifies — anonymous access is purely additive.