diff --git a/.claude/workflows/docs-accuracy-audit.js b/.claude/workflows/docs-accuracy-audit.js
index c43448f066..a34c5b60b3 100644
--- a/.claude/workflows/docs-accuracy-audit.js
+++ b/.claude/workflows/docs-accuracy-audit.js
@@ -210,6 +210,7 @@ const ALL_HANDWRITTEN = [
"content/docs/ui/pages.mdx",
"content/docs/ui/public-data-collection.mdx",
"content/docs/ui/react-pages.mdx",
+ "content/docs/ui/reports.mdx",
"content/docs/ui/setup-app.mdx",
"content/docs/ui/translations.mdx",
"content/docs/ui/views.mdx",
diff --git a/content/docs/ui/doc-pages.mdx b/content/docs/ui/doc-pages.mdx
index e69772e49c..35a22a6a39 100644
--- a/content/docs/ui/doc-pages.mdx
+++ b/content/docs/ui/doc-pages.mdx
@@ -1,6 +1,6 @@
---
title: Doc Metadata
-description: Ship package documentation as metadata — flat src/docs/*.md files compiled into the manifest and rendered in the console
+description: 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
---
# Doc Metadata
@@ -157,6 +157,170 @@ The CRM package manages accounts, contacts, and opportunities.
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](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0046-package-docs-as-metadata.md) §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
+
+| 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 | **not read from frontmatter** — see below |
+
+
+**`tags` cannot be set from a `src/docs/*.md` file today.** The frontmatter reader
+extracts single-line scalars — `title`, `description`, `order`, `group` — and has no case
+for a list, so a `tags:` block in a Markdown doc is not collected and the doc reaches the
+resolver with no tags. The schema key and the matcher are both live, so a doc declared
+programmatically in a stack's `docs` array does carry tags and does match. For the flat
+Markdown path, express the grouping with a name glob instead.
+
+
+### 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:
+
+{/* os:check */}
+```typescript
+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, three scalars that let a spine place it
+ (`order`, `group`, `tags`). 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//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:
diff --git a/content/docs/ui/meta.json b/content/docs/ui/meta.json
index 3643f6e4e1..1464d86d2f 100644
--- a/content/docs/ui/meta.json
+++ b/content/docs/ui/meta.json
@@ -8,6 +8,7 @@
"views",
"actions",
"dashboards",
+ "reports",
"translations",
"forms",
"doc-pages",
diff --git a/content/docs/ui/reports.mdx b/content/docs/ui/reports.mdx
new file mode 100644
index 0000000000..24b1c919bb
--- /dev/null
+++ b/content/docs/ui/reports.mdx
@@ -0,0 +1,258 @@
+---
+title: Report Metadata
+description: Analytics reports as metadata — the four report shapes, dataset binding, drill-through, and how a report differs from a list view and a dashboard widget
+---
+
+# Report Metadata
+
+A **Report** is an analytics artifact. It groups and aggregates the rows of a
+[dataset](/docs/data-modeling/analytics) into a pivot — grouped down-axis rows,
+optionally pivoted across a second axis, with measures in the cells — and gives that
+pivot its own page in the app.
+
+Three surfaces in this module look similar from a distance and are not
+interchangeable. Pick by **what the reader is looking at**:
+
+| | A report shows | Bound to | Authored in | Reached at |
+| :--- | :--- | :--- | :--- | :--- |
+| **List view** | individual records, one row each | an **object** | that object's `views` | the object's nav entry |
+| **Dashboard widget** | one aggregate slice, sized into a tile grid | a **dataset** | a dashboard's `widgets[]` | inside its dashboard |
+| **Report** | a whole pivot — grouped rows, optional across-axis columns, aggregated cells, an optional chart, drill-through to the records | a **dataset** | `defineStack({ reports })` | its own nav entry and URL |
+
+The line between the first and the third is the one that actually gets crossed. A flat
+list of records is an **object-bound row lens** (ADR-0017), not analytics — so it belongs
+in a view, whatever it is called. The showcase package made exactly that move: its former
+`TaskListReport` is now the `tabular` list view on `showcase_task`, because a report that
+never aggregated anything was a view wearing the wrong kind.
+
+The line between the second and the third is scale, not capability: a widget is one slice
+sized into a dashboard's grid, a report is the full grid with its own page. Both bind
+datasets the same way, so the numbers agree by construction.
+
+## A report binds a dataset — and only a dataset
+
+
+**There is one data path, not two.** Under the **ADR-0021** single-form cutover a report
+is dataset-bound, full stop. The legacy inline query — `objectName` plus `columns` plus
+`groupings` on the report itself — was removed in that cutover, so there is no
+object-bound report to choose between. Writing `objectName`, `object`, `source` or
+`dataSet` is rejected at authoring time with a pointer at `dataset`.
+
+
+Every report except a `joined` one must declare `dataset` **and** a non-empty `values`;
+the schema refuses it otherwise, with the message *a report needs `dataset` + `values`
+(measure names)*. A `joined` report carries its data on `blocks[]` instead and must declare at
+least one block.
+
+The dataset owns the base object, the joins, and the named dimensions and measures. That
+is what keeps a number identical across every report, widget and dashboard that selects
+it. Dataset authoring — declaring dimensions and measures, and what `rows` / `values` /
+`runtimeFilter` select from them — is covered once, from the dataset side, in
+[Analytics & Datasets](/docs/data-modeling/analytics). This page covers the report shape
+on top of it.
+
+## The four report types
+
+`type` defaults to `tabular`.
+
+| `type` | Renders | Needs |
+| :--- | :--- | :--- |
+| `tabular` | the dataset's rows as a flat table | `dataset` + `values` |
+| `summary` | rows grouped down one or more dimensions, measures aggregated per group | `dataset` + `values`, and `rows` to group by |
+| `matrix` | a true pivot: `rows` down × `columns` across, measures in the cells | `dataset` + `values` + `rows` + `columns` |
+| `joined` | several independent sub-reports stacked in one page | `blocks[]` (each block dataset-bound) |
+
+### Summary — grouped totals
+
+{/* os:check */}
+```typescript
+import { defineReport } from '@objectstack/spec/ui';
+
+export const HoursByStatusReport = defineReport({
+ name: 'showcase_hours_by_status',
+ label: 'Hours by Status',
+ description: 'Estimated hours grouped by task status.',
+ type: 'summary',
+ dataset: 'showcase_task_metrics',
+ rows: ['status'], // dimension names, down the page
+ values: ['est_hours'], // measure names, aggregated per group
+});
+```
+
+### Matrix — a cross-tab
+
+`columns` is the across-axis and is read only by a `matrix` report; other types ignore it.
+
+{/* os:check */}
+```typescript
+import { defineReport } from '@objectstack/spec/ui';
+
+export const StatusPriorityMatrixReport = defineReport({
+ name: 'showcase_status_priority_matrix',
+ label: 'Status × Priority',
+ type: 'matrix',
+ dataset: 'showcase_task_metrics',
+ rows: ['status'], // down axis
+ columns: ['priority'], // across axis
+ values: ['est_hours'], // in the cells
+});
+```
+
+### Joined — several sub-reports in one page
+
+Each block is independently queried and stacked in the container. Use it for comparative
+panels over one domain — "open / completed", "new / qualified / closed" — where each panel
+is a different slice rather than a different subject.
+
+A block is a sub-report, so it takes the same `dataset` / `rows` / `columns` / `values` /
+`runtimeFilter` / `order` vocabulary. Four things are **container-level only** and are
+rejected on a block: nested `blocks` (no recursion — a block's `type` enum excludes
+`joined`), `drilldown`, `protection`, and — as below — `order` on a `joined` container.
+
+{/* os:check */}
+```typescript
+import { defineReport } from '@objectstack/spec/ui';
+
+export const TaskOverviewReport = defineReport({
+ name: 'showcase_task_overview',
+ label: 'Task Overview',
+ type: 'joined',
+ blocks: [
+ {
+ name: 'open_block',
+ label: 'Open Tasks',
+ type: 'summary',
+ dataset: 'showcase_task_metrics',
+ rows: ['status'],
+ values: ['est_hours'],
+ runtimeFilter: { done: false },
+ },
+ {
+ name: 'done_block',
+ label: 'Completed Tasks',
+ type: 'summary',
+ dataset: 'showcase_task_metrics',
+ rows: ['status'],
+ values: ['task_count'],
+ runtimeFilter: { done: true },
+ },
+ ],
+});
+```
+
+## Ordering
+
+`order` is a **list** of sort keys, most significant first — an array rather than a map,
+because the key order is the sort significance and JSON object key order is not a contract
+you should have to lean on. Each key is `{ by, direction }`, `direction` defaulting to
+`asc`.
+
+Two rules are enforced when the report is authored, not discovered when it renders:
+
+- **`by` must name something this report selects** — a `rows` / `columns` dimension or a
+ `values` measure. Anything else is an authoring error rather than an ordering that
+ silently does nothing.
+- **A `joined` report orders per block.** `order` on the container is rejected with
+ *a `joined` report orders per block — move `order` onto `blocks[]`*.
+
+Ordering is optional: a selected date dimension already comes back chronological. What
+`order` is for — sorting by a measure, reversing a time axis, ordering a non-time
+dimension — and how it is applied server-side over the whole grid is covered in
+[Analytics & Datasets](/docs/data-modeling/analytics), which this page does not repeat.
+
+## Drill-through
+
+`drilldown` is a **boolean**, on by default (ADR-0021 D2). It turns click-through from an
+aggregated row or cell to the underlying records on or off for a `summary` / `matrix`
+report; the host resolves the dataset's object and its dimension-to-field mapping.
+
+
+`drillDown` — camelCase — is a **different capability on a different surface**: it is the
+react-tier `` prop, a configuration *object* that configures a
+chart segment drill. The report key is `drilldown`, all lowercase, and a plain boolean.
+The two are one character apart, so a rename suggestion would walk you straight into a
+second rejection — write `drilldown: true` / `false` if you mean the report.
+
+
+## An embedded chart
+
+A report may carry one `chart`. Its `xAxis` and `yAxis` name the **bound dataset's**
+dimension and measure — not raw object fields — and are plotted from a second dataset
+query, so the chart and the grid cannot disagree.
+
+{/* os:check */}
+```typescript
+import { defineReport } from '@objectstack/spec/ui';
+
+export const HoursByStatusChartReport = defineReport({
+ name: 'showcase_hours_by_status_chart',
+ label: 'Hours by Status (Chart)',
+ type: 'summary',
+ dataset: 'showcase_task_metrics',
+ rows: ['status'],
+ values: ['est_hours'],
+ chart: {
+ type: 'bar',
+ xAxis: 'status', // a dataset DIMENSION
+ yAxis: 'est_hours', // a dataset MEASURE
+ },
+});
+```
+
+## Making a report reachable
+
+A report is not reachable because it exists. Give it a navigation entry on an app:
+
+{/* os:check */}
+```typescript
+import { defineApp } from '@objectstack/spec/ui';
+
+export const AnalyticsApp = defineApp({
+ name: 'showcase_analytics',
+ label: 'Analytics',
+ navigation: [
+ {
+ id: 'nav_hours_by_status',
+ type: 'report',
+ reportName: 'showcase_hours_by_status',
+ label: 'Hours by Status',
+ icon: 'chart-bar',
+ },
+ ],
+});
+```
+
+`reportName` is **cross-checked against the stack**: an app navigating to a report the
+stack does not define fails validation with `App '' navigation references report
+'' which is not defined in reports.` (The check is skipped for a stack that declares
+no reports at all, where the target may come from another package.) The entry resolves to
+`/report/` in the console.
+
+## Permissions and protection
+
+The report schema carries exactly **one** access-shaped block, and it is not about
+viewers:
+
+- **`protection`** — the ADR-0010 package-author lock policy, declared once on the report
+ (never per block). The loader translates it into the runtime protection envelope at
+ registration time. It governs what an *installing org* may modify, not who may read the
+ numbers.
+
+There is deliberately no viewer-permission key on a report. Who may see what comes from
+the two layers underneath it: **row- and tenant-level security is enforced by the runtime
+per joined object** when the dataset is queried — never declared on the dataset and
+never on the report — and **reachability** comes from the app navigation entry and the
+permissions on the app that carries it. A report is a presentation over a dataset, so it
+inherits the dataset's enforcement rather than restating it.
+
+## Related
+
+- **Schema reference:** [Report](/docs/references/ui/report) — the full generated property
+ tables for `Report`, `JoinedReportBlock`, `ReportChart` and `ReportSort`.
+- [Analytics & Datasets](/docs/data-modeling/analytics) — declaring the dataset a report
+ binds, and the ordering and filter-placeholder semantics shared with dashboards.
+- [Dashboard Metadata](/docs/ui/dashboards) — the same dataset binding, sized into a tile
+ grid.
+- [View Metadata](/docs/ui/views) — the object-bound row lens a flat record list belongs
+ in.
+- [App Metadata](/docs/ui/apps) — navigation entries, including `type: 'report'`.