Skip to content

Commit 71578f2

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(book): documentation navigation spine + derived membership (ADR-0046 §6, P3a/P3b) (#1879)
* docs(adr-0046): P3 increment — `book` navigation as a spine with derived membership Adds §6 to ADR-0046 specifying the P3 navigation model. A `book` is a metadata element that stores only an ordered spine (groups); membership is derived by rule (`include` glob/tag) plus optional per-doc `order`/`group`, never a central array. This is chosen so AI authoring stays create-and-forget (no central-array read-modify-write, §6.2.1) and runtime overlay stays merge-safe under RFC 7396. Also specified: the implicit per-package book (packageId), `audience` access (org / public≡guest / {profile}) enforced at the read layer, and a host-based anonymous "library" portal reusing apps/docs (Fumadocs + per-host ISR) rather than greenfield SSR. No `meta.json`: `book` is metadata, authored as *.book.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(spec): add `book` navigation element — spine + derived-membership resolver (ADR-0046 §6, P3a) - BookSchema (spine: ordered groups + audience + identity), BookGroupSchema, BookNodeSchema, defineBook(); additive scalar `doc.order` / `doc.group`. - resolveBookTree(): pure derived-membership resolver — the AI-safety core. A doc joins a group by `include` glob/tag or explicit `doc.group`; explicit `pages` override (verbatim order, `---`, `...` rest, missing-doc, object nodes); unmatched docs → synthetic Uncategorized; first-group-wins dedup; package-scoped includes. - deriveImplicitPackageBook(): the implicit per-package book (no "flat" fork). - Register `book` metadata type (allowOrgOverride:true, loadOrder 99); artifact field `books` → `book`; `stack.books`. - 15 resolver/schema tests; spec tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(book): REST resolved-tree endpoint + audience gating + runtime wiring (ADR-0046 §6, P3b) - REST `GET /meta/book/:name/tree` resolves a book spine against the docs that exist now into a rendered tree (resolveBookTree); unknown name → implicit per-package book. Read-layer `audience` gating: anonymous callers get a book only when its audience is `public` (else 401), enforced in the read path — not just the UI — since /meta reads are anonymous-reachable. - Register `book` across the runtime metadata enumerations that were missing it: PLURAL_TO_SINGULAR (`books`→`book`), engine.registerApp metadataArrayKeys, app-plugin app-category keys, and the builtin type-schema map (book→BookSchema for Studio editing / diagnostics). Export `defineBook` from the spec root. - Showcase ships a real `showcase_manual` book (audience: public) demonstrating explicit `pages` + `...` rest (start) and a derived `include` rule (guides). Verified live (artifact boot): GET /meta/book → 1 item; /tree returns the derived tree (guides filled by rule, other-package docs → Uncategorized); public book serves anonymously (200), implicit org book is gated (401). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: changeset for book navigation (ADR-0046 §6) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b9d0526 commit 71578f2

17 files changed

Lines changed: 625 additions & 54 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/rest": minor
5+
"@objectstack/runtime": patch
6+
---
7+
8+
feat(book): documentation navigation as a `book` element — spine + derived membership (ADR-0046 §6)
9+
10+
Adds the `book` metadata element: a navigation **spine** (ordered groups + `audience` + identity) whose membership is **derived** by rule (`include` glob/tag) plus optional per-doc `order`/`group`, never a central array. This keeps AI authoring create-and-forget (no central-array read-modify-write) and runtime overlay merge-safe (RFC 7396 treats arrays atomically).
11+
12+
- `BookSchema` + `resolveBookTree()` derived-membership resolver + `defineBook()` + additive `doc.order`/`doc.group`.
13+
- Register `book` as a render-time metadata type (`allowOrgOverride: true`); wire it through the runtime type enumerations (PLURAL_TO_SINGULAR, engine registration, artifact field map, type-schema map).
14+
- REST `GET /meta/book/:name/tree` resolves the tree; read-layer `audience` gating (`public` ≡ anonymous; `org`/`{profile}` require sign-in).

.claude/launch.json

Lines changed: 0 additions & 52 deletions
This file was deleted.

examples/app-showcase/objectstack.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { allWebhooks } from './src/webhooks/index.js';
2424
import { allJobs } from './src/jobs/index.js';
2525
import { allEmails } from './src/emails/index.js';
2626
import { ShowcaseAssistantAgent, ProjectOpsSkill } from './src/agents/index.js';
27+
import { allBooks } from './src/books/index.js';
2728
import {
2829
allRoles,
2930
allPermissionSets,
@@ -143,6 +144,7 @@ export default defineStack({
143144
views: [TaskViews, ProjectViews],
144145
pages: [ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage],
145146
dashboards: [ChartGalleryDashboard],
147+
books: allBooks,
146148
datasets: [ShowcaseTaskDataset, ShowcaseProjectDataset],
147149
reports: allReports,
148150
actions: allActions,
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { defineBook } from '@objectstack/spec';
4+
5+
/**
6+
* Showcase documentation book (ADR-0046 §6). A spine only: groups are ordered
7+
* and curated, but membership is DERIVED — `guides` picks up any
8+
* `showcase_*_guide` doc by rule, so a new guide files itself with no edit to
9+
* this book. `audience: 'public'` exposes it anonymously via the library
10+
* portal so the resolved tree can be verified without a session.
11+
*/
12+
export const ShowcaseBook = defineBook({
13+
name: 'showcase_manual',
14+
label: 'Showcase Manual',
15+
description: 'Everything in the kitchen-sink workspace.',
16+
slug: 'manual',
17+
order: 0,
18+
audience: 'public',
19+
groups: [
20+
{
21+
key: 'start',
22+
label: 'Getting Started',
23+
order: 1,
24+
// Explicit override: pin the index first, then sweep any other intro docs.
25+
pages: ['showcase_index', '...'],
26+
},
27+
{
28+
key: 'guides',
29+
label: 'Guides',
30+
order: 2,
31+
include: 'showcase_*_guide', // derived: every guide doc, present and future
32+
},
33+
],
34+
});
35+
36+
export const allBooks = [ShowcaseBook];

packages/metadata/src/plugin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ const ARTIFACT_FIELD_TO_TYPE: Record<string, string> = {
7272
connectors: 'connector',
7373
emailTemplates: 'email_template',
7474
docs: 'doc',
75+
books: 'book',
7576
data: 'dataset',
7677
};
7778

packages/objectql/src/engine.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -938,6 +938,8 @@ export class ObjectQL implements IDataEngine {
938938
'connectors',
939939
// System Protocol — package documentation (ADR-0046); inert data
940940
'docs',
941+
// Documentation navigation spine (ADR-0046 §6)
942+
'books',
941943
];
942944
for (const key of metadataArrayKeys) {
943945
const items = (manifest as any)[key];
@@ -1083,7 +1085,7 @@ export class ObjectQL implements IDataEngine {
10831085
'roles', 'permissions', 'profiles', 'sharingRules', 'policies',
10841086
'agents', 'ragPipelines', 'apis',
10851087
'hooks', 'mappings', 'analyticsCubes', 'connectors',
1086-
'docs',
1088+
'docs', 'books',
10871089
];
10881090
for (const key of metadataArrayKeys) {
10891091
const items = (plugin as any)[key];

packages/rest/src/rest-server.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1922,6 +1922,74 @@ export class RestServer {
19221922
},
19231923
});
19241924

1925+
// ADR-0046 §6 — GET /meta/book/:name/tree
1926+
// Resolve a book spine against the docs that exist *now* into a
1927+
// rendered tree (membership is DERIVED, never stored — §6.2.1). An
1928+
// unknown name is treated as a package id and resolved against the
1929+
// implicit per-package book (§6.4). Anonymous requests only see a
1930+
// book whose `audience` is `public` (§6.7 read-layer gating).
1931+
this.routeManager.register({
1932+
method: 'GET',
1933+
path: `${metaPath}/book/:name/tree`,
1934+
handler: async (req: any, res: any) => {
1935+
try {
1936+
const environmentId = isScoped ? req.params?.environmentId : undefined;
1937+
const prot = await this.resolveProtocol(environmentId, req);
1938+
const locale = this.extractLocale(req);
1939+
const packageId = req.query?.package || undefined;
1940+
const { resolveBookTree, deriveImplicitPackageBook, isPublicAudience, resolveDocLocale } =
1941+
await import('@objectstack/spec/system');
1942+
1943+
const norm = (raw: any): any[] =>
1944+
Array.isArray(raw) ? raw : (raw && Array.isArray(raw.items) ? raw.items : []);
1945+
1946+
const books = norm(await prot.getMetaItems({
1947+
type: 'book',
1948+
...(packageId ? { packageId } : {}),
1949+
...(environmentId ? { environmentId } : {}),
1950+
} as any));
1951+
let book = books.find((b: any) => b && b.name === req.params.name);
1952+
if (!book) {
1953+
// Unknown name → the implicit per-package book (§6.4).
1954+
book = deriveImplicitPackageBook(req.params.name, req.params.name);
1955+
}
1956+
1957+
// §6.7 — anonymous callers only get `public` books.
1958+
const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined);
1959+
if (!ctx?.userId && !isPublicAudience((book as any).audience)) {
1960+
sendError(res, { code: 'unauthorized', message: 'This documentation requires sign-in', status: 401 });
1961+
return;
1962+
}
1963+
1964+
const docs = norm(await prot.getMetaItems({
1965+
type: 'doc',
1966+
...(packageId ? { packageId } : {}),
1967+
...(environmentId ? { environmentId } : {}),
1968+
} as any))
1969+
.map((d: any) => (d && typeof d === 'object' ? resolveDocLocale(d, locale) : d))
1970+
.map((d: any) => ({
1971+
name: d.name,
1972+
label: d.label,
1973+
description: d.description,
1974+
order: d.order,
1975+
group: d.group,
1976+
tags: d.tags,
1977+
packageId: d._packageId,
1978+
}));
1979+
1980+
const tree = resolveBookTree(book as any, docs, (book as any)._packageId);
1981+
res.json(tree);
1982+
} catch (error: any) {
1983+
logError("[REST] Unhandled error:", error);
1984+
sendError(res, error);
1985+
}
1986+
},
1987+
metadata: {
1988+
summary: 'Resolve a documentation book spine into its rendered tree (ADR-0046 §6)',
1989+
tags: ['metadata'],
1990+
},
1991+
});
1992+
19251993
this.routeManager.register({
19261994
method: 'GET',
19271995
path: `${metaPath}/:type/:name`,

packages/runtime/src/app-plugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export class AppPlugin implements Plugin {
6969
'flows', 'workflows', 'triggers', 'agents', 'tools', 'skills',
7070
'actions', 'permissions', 'roles', 'profiles', 'translations',
7171
'sharingRules', 'ragPipelines', 'data', 'emailTemplates',
72-
'docs',
72+
'docs', 'books',
7373
];
7474
const hasAppPayload = APP_CATEGORY_KEYS.some((k) => {
7575
const v = (bundle && bundle[k]) ?? (sys && sys[k]);

packages/spec/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export type { ExpandedViewItem } from './ui/view.zod';
7676
export { defineApp } from './ui/app.zod';
7777
export { defineFlow } from './automation/flow.zod';
7878
export { defineJob } from './system/job.zod';
79+
export { defineBook } from './system/book.zod';
7980
export { defineAgent } from './ai/agent.zod';
8081
export { defineTool } from './ai/tool.zod';
8182
export { defineSkill } from './ai/skill.zod';

packages/spec/src/kernel/metadata-plugin.zod.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export const MetadataTypeSchema = lazySchema(() => z.enum([
105105
'service', // Service definitions
106106
'email_template', // Outbound email templates (EmailTemplateSchema)
107107
'doc', // Package documentation — flat Markdown items (DocSchema, ADR-0046)
108+
'book', // Documentation navigation spine (BookSchema, ADR-0046 §6)
108109

109110
// Security Protocol
110111
'permission', // Permission sets (PermissionSetSchema)
@@ -666,6 +667,10 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntry[] = [
666667
// (ADR-0033). Collected from flat `src/docs/*.md` by the CLI; the kernel
667668
// never parses `content`. loadOrder is last: nothing references docs.
668669
{ type: 'doc', label: 'Documentation', description: 'Package documentation — flat Markdown items (ADR-0046)', filePatterns: ['**/docs/*.md'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 99, domain: 'system' },
670+
// Navigation spine over docs (ADR-0046 §6): ordered groups, membership derived
671+
// by rule. Render-time like view/dashboard ⇒ overlay-allowed so Studio can
672+
// drag-edit; runtime-creatable for AI/authors. loadOrder last (references docs).
673+
{ type: 'book', label: 'Documentation Book', description: 'Documentation navigation spine — ordered groups with derived membership (ADR-0046 §6)', filePatterns: ['**/*.book.ts'], supportsOverlay: true, allowOrgOverride: true, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 99, domain: 'system' },
669674

670675
// Security Protocol
671676
{ type: 'permission', label: 'Permission Set', filePatterns: ['**/*.permission.ts', '**/*.permission.yml'], supportsOverlay: true, allowOrgOverride: true, allowRuntimeCreate: true, supportsVersioning: true, executionPinned: false, loadOrder: 15, domain: 'security' },

0 commit comments

Comments
 (0)