ObjectStackObjectStack

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:

PropertyTypeRequiredDescription
namestringMachine 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
labelstringDisplay title (from frontmatter title)
descriptionstringOne-line summary for listings (from frontmatter description)
contentstringThe 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> on GET /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 #section deep-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.

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:

  1. Groups are ordered by group.order, ties broken by declaration order.
  2. Each doc joins the first group that claims it — the first group, in that order, whose include rule matches the doc or whose key equals the doc's own group. First claim wins, so a doc never appears twice.
  3. Within a group, docs sort by doc.order, then by label (falling back to the doc name).
  4. 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's tags. 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

KeyEffectSet from
ordersort position within the group that claims the docfrontmatter order:
groupexplicit placement — the key of the group this doc belongs to, used when no rule expresses itfrontmatter group:
tagsthe operand of a group's include: { tag } rulefrontmatter 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

PropertyTypeRequiredDescription
namestringMachine name (snake_case), namespace-prefixed like every metadata name
labelstringDisplay title
descriptionstringOne-line summary
slugstringPortal URL segment; defaults to the name without its prefix
iconstringIcon name
ordernumberOrders this book among the portal's books
audience'org' | 'public' | { permissionSet }Who may read it; defaults to 'org'
groupsBookGroup[]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 order and group scalars and the tags list. It names no book.
  • A book carries the structure and names no docs — except in a pages override, 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 audience gates 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.

On this page