Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/book-navigation-spine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@objectstack/spec": minor
"@objectstack/objectql": minor
"@objectstack/rest": minor
"@objectstack/runtime": patch
---

feat(book): documentation navigation as a `book` element — spine + derived membership (ADR-0046 §6)

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).

- `BookSchema` + `resolveBookTree()` derived-membership resolver + `defineBook()` + additive `doc.order`/`doc.group`.
- 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).
- REST `GET /meta/book/:name/tree` resolves the tree; read-layer `audience` gating (`public` ≡ anonymous; `org`/`{profile}` require sign-in).
52 changes: 0 additions & 52 deletions .claude/launch.json

This file was deleted.

354 changes: 354 additions & 0 deletions docs/adr/0046-package-docs-as-metadata.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { allWebhooks } from './src/webhooks/index.js';
import { allJobs } from './src/jobs/index.js';
import { allEmails } from './src/emails/index.js';
import { ShowcaseAssistantAgent, ProjectOpsSkill } from './src/agents/index.js';
import { allBooks } from './src/books/index.js';
import {
allRoles,
allPermissionSets,
Expand Down Expand Up @@ -143,6 +144,7 @@ export default defineStack({
views: [TaskViews, ProjectViews],
pages: [ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage],
dashboards: [ChartGalleryDashboard],
books: allBooks,
datasets: [ShowcaseTaskDataset, ShowcaseProjectDataset],
reports: allReports,
actions: allActions,
Expand Down
36 changes: 36 additions & 0 deletions examples/app-showcase/src/books/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { defineBook } from '@objectstack/spec';

/**
* Showcase documentation book (ADR-0046 §6). A spine only: groups are ordered
* and curated, but membership is DERIVED — `guides` picks up any
* `showcase_*_guide` doc by rule, so a new guide files itself with no edit to
* this book. `audience: 'public'` exposes it anonymously via the library
* portal so the resolved tree can be verified without a session.
*/
export const ShowcaseBook = defineBook({
name: 'showcase_manual',
label: 'Showcase Manual',
description: 'Everything in the kitchen-sink workspace.',
slug: 'manual',
order: 0,
audience: 'public',
groups: [
{
key: 'start',
label: 'Getting Started',
order: 1,
// Explicit override: pin the index first, then sweep any other intro docs.
pages: ['showcase_index', '...'],
},
{
key: 'guides',
label: 'Guides',
order: 2,
include: 'showcase_*_guide', // derived: every guide doc, present and future
},
],
});

export const allBooks = [ShowcaseBook];
1 change: 1 addition & 0 deletions packages/metadata/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ const ARTIFACT_FIELD_TO_TYPE: Record<string, string> = {
connectors: 'connector',
emailTemplates: 'email_template',
docs: 'doc',
books: 'book',
data: 'dataset',
};

Expand Down
4 changes: 3 additions & 1 deletion packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,8 @@ export class ObjectQL implements IDataEngine {
'connectors',
// System Protocol — package documentation (ADR-0046); inert data
'docs',
// Documentation navigation spine (ADR-0046 §6)
'books',
];
for (const key of metadataArrayKeys) {
const items = (manifest as any)[key];
Expand Down Expand Up @@ -1083,7 +1085,7 @@ export class ObjectQL implements IDataEngine {
'roles', 'permissions', 'profiles', 'sharingRules', 'policies',
'agents', 'ragPipelines', 'apis',
'hooks', 'mappings', 'analyticsCubes', 'connectors',
'docs',
'docs', 'books',
];
for (const key of metadataArrayKeys) {
const items = (plugin as any)[key];
Expand Down
68 changes: 68 additions & 0 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1922,6 +1922,74 @@ export class RestServer {
},
});

// ADR-0046 §6 — GET /meta/book/:name/tree
// Resolve a book spine against the docs that exist *now* into a
// rendered tree (membership is DERIVED, never stored — §6.2.1). An
// unknown name is treated as a package id and resolved against the
// implicit per-package book (§6.4). Anonymous requests only see a
// book whose `audience` is `public` (§6.7 read-layer gating).
this.routeManager.register({
method: 'GET',
path: `${metaPath}/book/:name/tree`,
handler: async (req: any, res: any) => {
try {
const environmentId = isScoped ? req.params?.environmentId : undefined;
const prot = await this.resolveProtocol(environmentId, req);
const locale = this.extractLocale(req);
const packageId = req.query?.package || undefined;
const { resolveBookTree, deriveImplicitPackageBook, isPublicAudience, resolveDocLocale } =
await import('@objectstack/spec/system');

const norm = (raw: any): any[] =>
Array.isArray(raw) ? raw : (raw && Array.isArray(raw.items) ? raw.items : []);

const books = norm(await prot.getMetaItems({
type: 'book',
...(packageId ? { packageId } : {}),
...(environmentId ? { environmentId } : {}),
} as any));
let book = books.find((b: any) => b && b.name === req.params.name);
if (!book) {
// Unknown name → the implicit per-package book (§6.4).
book = deriveImplicitPackageBook(req.params.name, req.params.name);
}

// §6.7 — anonymous callers only get `public` books.
const ctx = await this.resolveExecCtx(environmentId, req).catch(() => undefined);
if (!ctx?.userId && !isPublicAudience((book as any).audience)) {
sendError(res, { code: 'unauthorized', message: 'This documentation requires sign-in', status: 401 });
return;
}

const docs = norm(await prot.getMetaItems({
type: 'doc',
...(packageId ? { packageId } : {}),
...(environmentId ? { environmentId } : {}),
} as any))
.map((d: any) => (d && typeof d === 'object' ? resolveDocLocale(d, locale) : d))
.map((d: any) => ({
name: d.name,
label: d.label,
description: d.description,
order: d.order,
group: d.group,
tags: d.tags,
packageId: d._packageId,
}));

const tree = resolveBookTree(book as any, docs, (book as any)._packageId);
res.json(tree);
} catch (error: any) {
logError("[REST] Unhandled error:", error);
sendError(res, error);
}
},
metadata: {
summary: 'Resolve a documentation book spine into its rendered tree (ADR-0046 §6)',
tags: ['metadata'],
},
});

this.routeManager.register({
method: 'GET',
path: `${metaPath}/:type/:name`,
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime/src/app-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class AppPlugin implements Plugin {
'flows', 'workflows', 'triggers', 'agents', 'tools', 'skills',
'actions', 'permissions', 'roles', 'profiles', 'translations',
'sharingRules', 'ragPipelines', 'data', 'emailTemplates',
'docs',
'docs', 'books',
];
const hasAppPayload = APP_CATEGORY_KEYS.some((k) => {
const v = (bundle && bundle[k]) ?? (sys && sys[k]);
Expand Down
1 change: 1 addition & 0 deletions packages/spec/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export type { ExpandedViewItem } from './ui/view.zod';
export { defineApp } from './ui/app.zod';
export { defineFlow } from './automation/flow.zod';
export { defineJob } from './system/job.zod';
export { defineBook } from './system/book.zod';
export { defineAgent } from './ai/agent.zod';
export { defineTool } from './ai/tool.zod';
export { defineSkill } from './ai/skill.zod';
Expand Down
5 changes: 5 additions & 0 deletions packages/spec/src/kernel/metadata-plugin.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export const MetadataTypeSchema = lazySchema(() => z.enum([
'service', // Service definitions
'email_template', // Outbound email templates (EmailTemplateSchema)
'doc', // Package documentation — flat Markdown items (DocSchema, ADR-0046)
'book', // Documentation navigation spine (BookSchema, ADR-0046 §6)

// Security Protocol
'permission', // Permission sets (PermissionSetSchema)
Expand Down Expand Up @@ -666,6 +667,10 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntry[] = [
// (ADR-0033). Collected from flat `src/docs/*.md` by the CLI; the kernel
// never parses `content`. loadOrder is last: nothing references docs.
{ 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' },
// Navigation spine over docs (ADR-0046 §6): ordered groups, membership derived
// by rule. Render-time like view/dashboard ⇒ overlay-allowed so Studio can
// drag-edit; runtime-creatable for AI/authors. loadOrder last (references docs).
{ 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' },

// Security Protocol
{ type: 'permission', label: 'Permission Set', filePatterns: ['**/*.permission.ts', '**/*.permission.yml'], supportsOverlay: true, allowOrgOverride: true, allowRuntimeCreate: true, supportsVersioning: true, executionPinned: false, loadOrder: 15, domain: 'security' },
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/src/kernel/metadata-type-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { JobSchema } from '../system/job.zod';
import { EmailTemplateDefinitionSchema } from '../system/email-template.zod';
import { AppTranslationBundleSchema } from '../system/translation.zod';
import { DocSchema } from '../system/doc.zod';
import { BookSchema } from '../system/book.zod';

import { PermissionSetSchema } from '../security/permission.zod';
import { RoleSchema } from '../identity/role.zod';
Expand Down Expand Up @@ -97,6 +98,7 @@ const BUILTIN_METADATA_TYPE_SCHEMAS: Partial<Record<MetadataType, z.ZodType>> =
translation: AppTranslationBundleSchema,
email_template: EmailTemplateDefinitionSchema,
doc: DocSchema, // ADR-0046: flat Markdown package documentation
book: BookSchema as unknown as z.ZodType, // ADR-0046 §6: documentation navigation spine
// `router` / `function` / `service` are code-only (allowRuntimeCreate: false).

// Security Protocol
Expand Down
1 change: 1 addition & 0 deletions packages/spec/src/shared/metadata-collection.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export const PLURAL_TO_SINGULAR: Record<string, string> = {
views: 'view',
emailTemplates: 'email_template',
docs: 'doc',
books: 'book',
};

/** Reverse mapping: singular metadata type → plural manifest field name. */
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/src/stack.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { WebhookSchema } from './automation/webhook.zod';
// System Protocol (additional)
import { EmailTemplateDefinitionSchema } from './system/email-template.zod';
import { DocSchema } from './system/doc.zod';
import { BookSchema } from './system/book.zod';

// Integration Protocol
import { ConnectorSchema } from './integration/connector.zod';
Expand Down Expand Up @@ -226,6 +227,7 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({
jobs: z.array(JobSchema).optional().describe('Background / Scheduled Jobs (run by IJobService on cron/interval/once schedules)'),
emailTemplates: z.array(EmailTemplateDefinitionSchema).optional().describe('Email Templates resolved by IEmailService.sendTemplate({ template, locale })'),
docs: z.array(DocSchema).optional().describe('Package documentation — flat Markdown items compiled from src/docs/*.md (ADR-0046)'),
books: z.array(BookSchema).optional().describe('Documentation navigation spines — ordered groups with derived membership (ADR-0046 §6)'),

/**
* ObjectGuard: Security Layer
Expand Down
Loading