Doc Metadata
Ship package documentation as metadata — flat src/docs/*.md files compiled into the manifest and rendered in the console, ordered by a book navigation spine
A Doc is a single page of package documentation. You write plain
Markdown files in a flat src/docs/ directory; os build compiles each
one into a doc metadata item that ships inside the package artifact and
renders in the console at /docs/<name>. Docs are also the grounding the
AI assistant reads when answering questions about your package.
Unlike content/docs/ (which builds a standalone website), package docs
are package source — they travel with the package, version with it,
and are addressable as metadata like any Object or View.
Authoring: flat src/docs/*.md
The normal way to author a doc is to drop a Markdown file in src/docs/:
src/docs/
crm_index.md → doc name "crm_index"
crm_user_guide.md → doc name "crm_user_guide"The filename stem becomes the doc name. There is no directory taxonomy
and no ordering file — the flat layout is what keeps cross-references
stable (a link resolves by basename, never by path).
Frontmatter
A leading YAML frontmatter block is optional. title and description
are read:
---
title: User Guide
description: How to create, organize, and complete records in CRM.
---
# Getting started with CRM
...The display title resolves in order: frontmatter title: → the first
# heading → the doc name. The optional description is a one-line
summary the docs portal renders under the title (it travels in the doc
list, so the portal shows summaries without fetching each body).
Doc Properties
When a *.md file is collected, it becomes a doc item with this shape:
| Property | Type | Required | Description |
|---|---|---|---|
name | string | ✅ | Machine name (snake_case), from the filename stem. Must match ^[a-z][a-z0-9_]*$. The build lint still requires a namespace prefix for authoring hygiene (ADR-0048), though doc resolution is package-scoped |
label | string | — | Display title (from frontmatter title) |
description | string | — | One-line summary for listings (from frontmatter description) |
content | string | ✅ | The Markdown body |
You can also declare a doc programmatically with DocSchema in a stack
definition's docs array, but the src/docs/*.md path is the convention
— it gives you native editor and GitHub preview for free.
Naming and routing
A doc renders under /docs/<name> (in dev os serve mounts each
collected doc there). The build lint still requires every doc name to be
namespace-prefixed, as a same-package authoring-hygiene rule:
src/docs/crm_user_guide.md ✅ package namespace is "crm"
src/docs/user_guide.md ❌ bare name — lint error (docs/namespace-prefix)This is the same namespace-prefix lint the platform applies to other
named metadata for readable, globally-unique filenames. It is no longer
load-bearing for uniqueness, however: per ADR-0048, doc
resolution is package-scoped. Packaged items are stored under a
composite <packageId>:<name> registry key, and each retains its
_packageId, so two installed packages may each ship a doc with the same
bare name and coexist — neither silently overwrites the other.
Resolution model. Doc resolution is package-scoped (ADR-0048). The single-doc detail route resolves a name within a package id —
getItem('doc', name, packageId), surfaced as?package=<packageId>onGET /api/v1/meta/doc/<name>. A bare/docs/<name>link is best-effort (first match), while a caller that carries its package id resolves to its own package's doc. The earlier "one global URL per doc" framing from ADR-0046 was superseded by ADR-0048's package-scoped resolution.
Cross-references
Link to a sibling doc with a plain relative Markdown link:
See the [overview](./crm_index.md).The console rewrites *.md links to /docs/<target> (anchors are
preserved); in an editor or on GitHub the same link resolves natively.
Broken same-package links fail the build, so references can't rot
silently.
Markdown support
Docs render CommonMark + GFM through a sanitizing pipeline. Supported out of the box:
- Standard Markdown — headings, lists, tables, blockquotes, inline/fenced code, links.
- Heading anchors — every heading gets a slug id and a hover anchor,
so
#sectiondeep-links work. - Syntax highlighting — fenced code blocks are highlighted by language.
- GitHub alerts —
> [!NOTE],> [!TIP],> [!WARNING], etc. render as callouts.
Two things are rejected at build time, by design:
- MDX / embedded components. Docs are publisher-supplied content rendered inside the platform; executing authored code would cross a trust boundary. Markdown is data, not code.
- Image references. Images need a content-addressed asset service
(a later, additive concern). Until then,
fails the build rather than producing broken<img>tags in a tenant.
For dynamic content (a live flow diagram, a record table), don't try to embed a component — link to the metadata by URL instead. The platform renders the live view; the doc just points at it.
Example
---
title: CRM Overview
---
# CRM
The CRM package manages accounts, contacts, and opportunities.
> [!TIP]
> New here? Start with the [user guide](./crm_user_guide.md).
## Objects
| Object | Purpose |
| :--- | :--- |
| `crm_account` | Companies and organizations |
| `crm_contact` | People at an account |Saved as src/docs/crm_index.md, this compiles to a crm_index doc and
renders at /docs/crm_index.
Navigation: the book spine
A doc is one page. A Book is the spine of a table of contents over many of them:
an ordered set of groups (sections), plus the book's own identity and access. Flat
src/docs/*.md files give you pages with no order; a book is what turns them into a
navigable structure — and it is the only thing that does.
A package ships zero or more books, and a book never owns content: one doc may
surface in two books, or in none. Books are authored in *.book.ts files.
Membership is derived, never stored
This is the load-bearing decision of the design (ADR-0046 §6.2.1), and it is why a book has no member array to keep up to date. A group declares a rule; the tree is computed against whatever docs exist at the moment it is requested.
Precisely what derives it, in the order it runs:
- Groups are ordered by
group.order, ties broken by declaration order. - Each doc joins the first group that claims it — the first group, in that order,
whose
includerule matches the doc or whosekeyequals the doc's owngroup. First claim wins, so a doc never appears twice. - Within a group, docs sort by
doc.order, then by label (falling back to the doc name). - Anything claimed by nobody is appended last in a synthetic Uncategorized group. Nothing is ever dropped.
So the AI-authoring property the design was built for holds: create a doc whose name matches a rule and it files itself. There is no central array to read, modify and write back — which is the edit that drops or reorders siblings when two authors do it at once, and the one that package overlay cannot merge.
An include rule takes one of two forms:
- A glob over doc names —
'crm_guide_*'. Only*is special and it is anchored to the whole name. - A tag —
{ tag: 'tutorial' }, matched against the doc'stags. Use it for membership that cuts across naming; prefer a name convention when one exists.
group.package scopes a rule to one package id (default: the book's own), so a group can
deliberately gather docs another package ships.
The three per-doc keys the spine reads
| Key | Effect | Set from |
|---|---|---|
order | sort position within the group that claims the doc | frontmatter order: |
group | explicit placement — the key of the group this doc belongs to, used when no rule expresses it | frontmatter group: |
tags | the operand of a group's include: { tag } rule | frontmatter tags: |
tags is read from src/docs/*.md frontmatter, in the two ordinary YAML sequence
spellings — inline tags: [tutorial, beginner], or a block of - item lines under a bare
tags:. What is collected is what a group's include: { tag } rule matches against, so a
doc authored as flat Markdown and one declared programmatically in a stack's docs array
place themselves the same way.
The reader is deliberately minimal and is not a YAML engine — nested or mapping items,
block scalars, and quoted items containing commas are outside it. A tags: in any other
spelling is reported, not dropped: a docs/frontmatter-tags warning quoting the
spelling it found, raised by os lint, os validate and os compile (os build is the
same command). os dev / os serve collect docs without linting them, so the warning
does not appear there. Tags belong to the doc rather than to one translation: a
<name>.<locale>.md variant that declares tags: raises the same rule and its tags must
move to the base file.
Prefer a name convention (include: "crm_guide_*") when one exists — that steer survives
as a preference, not as a limitation. Tags are for membership that cuts across naming.
Identity and access
| Property | Type | Required | Description |
|---|---|---|---|
name | string | ✅ | Machine name (snake_case), namespace-prefixed like every metadata name |
label | string | — | Display title |
description | string | — | One-line summary |
slug | string | — | Portal URL segment; defaults to the name without its prefix |
icon | string | — | Icon name |
order | number | — | Orders this book among the portal's books |
audience | 'org' | 'public' | { permissionSet } | — | Who may read it; defaults to 'org' |
groups | BookGroup[] | ✅ | The spine. Two levels total — groups, then entries |
audience is a reference into the permission model rather than a vocabulary of its own:
'org' (the default) inherits the package grant and admits any signed-in principal,
'public' is anonymously readable and indexable, and { permissionSet: 'crm_admin' }
admits a signed-in principal holding that named set. A caller whose holdings cannot be
resolved is denied — the gate fails closed.
The gate is a capability reference, never a distribution one: packages own permission
sets but never positions, so a package gating its own Admin Guide keeps provenance and
uninstall semantics intact. (ADR-0046 §6.7 sketched this as { profile }; the shipped
key is permissionSet, per ADR-0090.)
A worked example
Saved as src/books/crm_docs.book.ts, alongside the crm_index doc from the previous
section:
import { defineBook } from '@objectstack/spec/system';
export const CrmDocsBook = defineBook({
name: 'crm_docs',
label: 'CRM Documentation',
slug: 'crm',
audience: 'org',
groups: [
{
key: 'overview',
label: 'Overview',
order: 1,
// Hand-pinned order; `...` sweeps in anything else the rule would match.
include: 'crm_index*',
pages: ['crm_index', '---', '...'],
},
{
key: 'guides',
label: 'User Guides',
order: 2,
include: 'crm_guide_*', // crm_guide_leads, crm_guide_accounts, …
},
{
key: 'admin',
label: 'Administration',
order: 3,
include: 'crm_admin_*',
// This section alone is gated; the rest of the book stays 'org'-visible
// because the doc's effective audience is the union over claiming books.
},
],
});A group may pin its order by hand instead of deriving it. pages wins over include for
that group and takes doc names plus two literals: '---' renders a separator, and '...'
expands to the rest — every doc the group's rule would have claimed but that no entry
names, in order-then-label sequence. An entry can also be an object to attach a label
override, a badge or an icon, or to point at an external href instead of a doc.
Inline translations on a book or a book group is rejected: no resolver ever read
it, so a localized spine shipped its authoring-locale strings to every reader. The near
neighbour that does work is doc.translations, read on every doc render path — localize
the docs themselves.
How doc and book compose
The two kinds have a clean split, and the direction of reference only goes one way:
- A doc carries the content and, optionally, the three placement keys a spine reads —
the
orderandgroupscalars and thetagslist. It names no book. - A book carries the structure and names no docs — except in a
pagesoverride, which is the deliberate escape hatch.
The rendered tree is resolved on read, not on write:
GET /api/v1/meta/book/<name>/tree fetches the book and the current doc set and returns
the resolved groups and entries. Two behaviours follow from that:
- A name that matches no authored book is treated as a package id and resolved against
the implicit per-package book — one group,
include: '*', audience'org'. There is no "flat versus book" fork in the model; a package that authors no book still has one, and that is what renders the flat case. - Access is filtered twice. The book's
audiencegates the whole tree (401 anonymous, 403 for a missing permission set), and then each entry is filtered by its doc's own effective audience — the union over every book claiming it, defaulting to'org'for a doc no book claims. An anonymous reader of a public book therefore never sees a nav entry that would fail on fetch. Orphans in the Uncategorized group are deliberately excluded from what a book "claims", so an unclaimed doc can never ride a public book out of the tenant.
Next Steps
- See the in-repo authoring reference in the showcase package:
examples/app-showcase/src/docs/showcase_docs_guide.md. - Read ADR-0046 for the full design rationale and rollout phases.