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
18 changes: 18 additions & 0 deletions .changeset/27091-book-tree-sibling-claims.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/spec': patch
'@objectstack/rest': patch
'@object-ui/console': patch
---

Clause-②: no
Metadata-protocol: none

fix(spec): a book's *Uncategorized* group no longer lists the pages a sibling book places (#27091)

When two books split one package's docs, for example a user manual and an admin manual gated by `audience: { permissionSet }`, each book's tree used to list the other book's pages under a trailing *Uncategorized* group. `resolveBookTree` judged "claimed" against the book's own groups only. ADR-0046 §6.4 rolls up only the docs "left unplaced", and a doc a sibling book places is placed.

- **`resolveBookTree(book, docs, bookPackage?, books?)`** takes an optional fourth argument, every registered book. With it, the *Uncategorized* group holds only the docs of the book's packages that no book claims. The claim judge is `resolveBookClaimedDocs` over each book, the same claim set `resolveDocAudiences` unions audiences over. Without it, the tree is the same as before.
- **`GET /meta/book/:name/tree`** passes the books it already reads, so an administrator opening the user manual no longer sees the admin pages under *Uncategorized*. A doc that no book claims still rolls up there.
- **The console's docs pages** render the book sidebar with the console's own copy of the resolver (`book-nav.ts`). It takes the same optional fourth argument, and the reader and the book landing pass the authored books, so the sidebar no longer lists a sibling book's pages under *Uncategorized* either.

The change only removes entries from the catch-all: a sibling book's docs appear nowhere new, and a doc's effective audience (§6.7) does not change.
9 changes: 7 additions & 2 deletions packages/rest/src/meta-item-read-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,9 @@ export interface DocsAudience {
/**
* `book` resolved over `docs`, narrowed to the entries this caller may read
* — exactly the body `GET /meta/book/:name/tree` serves once the book's own
* audience has admitted the caller. `canRead` is a {@link docReader} over
* audience has admitted the caller. Its *Uncategorized* group holds only
* the docs NO book of this audience claims (#27091), so a sibling book's
* pages never land there. `canRead` is a {@link docReader} over
* the same `docs` when the caller already holds one.
*/
readableTree(
Expand Down Expand Up @@ -702,7 +704,10 @@ export async function resolveDocsAudience(
admitsBook: (book: Book) => audienceAllows(book?.audience, caller),
docReader,
readableTree: (book, docs, canRead) => {
const tree = resolveBookTree(book, docs, book._packageId);
// [#27091] Every book this audience was built over judges the
// *Uncategorized* catch-all, so a doc a sibling book places is not
// this book's orphan (ADR-0046 §6.4).
const tree = resolveBookTree(book, docs, book._packageId, books);
// The fast path serves the tree whole — no entry can fail an
// audience every doc passes, and the empty-group drop below is
// part of the narrowing, not of the tree.
Expand Down
26 changes: 24 additions & 2 deletions packages/rest/src/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -749,8 +749,9 @@ describe('RestServer', () => {
const tree = res.json.mock.calls.at(-1)![0];
const docsInTree = tree.groups.flatMap((g: any) => g.entries.map((e: any) => e.doc));
expect(docsInTree).toContain('pub_intro');
// org_intro / gated_setup are claimed by other books; the orphan pass
// would render them, but the audience filter must drop them for anon.
// org_intro / gated_setup are claimed by other books, so they are not
// this book's orphans (#27091); the audience filter would drop them for
// anon even if they were.
expect(docsInTree).not.toContain('org_intro');
expect(docsInTree).not.toContain('gated_setup');
});
Expand Down Expand Up @@ -784,6 +785,27 @@ describe('RestServer', () => {
expect(docsInTree).toContain('gated_setup');
});

it('tree: [#27091] a holder of every book sees no sibling book\'s pages under Uncategorized', async () => {
// Three books split one package's docs: each doc is claimed by exactly
// one book, so no tree has a doc "left unplaced" (ADR-0046 §6.4). The
// holder reads every doc, so only the resolver can keep them out.
protocol.getMetaItems = metaByType([...BOOKS], [...DOCS, { name: 'loose_note', _packageId: 'pkg' }]);
const rest = new RestServer(server as any, protocol as any, ANON_API as any);
(rest as any).resolveExecCtx = vi.fn().mockResolvedValue({ userId: 'u1' });
(rest as any).securityServiceProvider = async () => ({
resolvePermissionSetNames: async () => ['member_default', 'crm_admin'],
});
rest.registerRoutes();
const route = getMetaRoute(rest, 'GET', '/api/v1/meta/book/:name/tree');
for (const [name, own] of [['org_book', 'org_intro'], ['gated_book', 'gated_setup']]) {
const res = mockRes();
await route!.handler({ params: { name }, query: {}, headers: {} }, res);
const tree = res.json.mock.calls.at(-1)![0];
expect(Object.fromEntries(tree.groups.map((g: any) => [g.key, g.entries.map((e: any) => e.doc)])))
.toEqual({ g: [own], uncategorized: ['loose_note'] });
}
});

it('list: anonymous /meta/book returns only public books', async () => {
protocol.getMetaItems = metaByType();
const rest = new RestServer(server as any, protocol as any, ANON_API as any);
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/liveness/doc.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"tags": {
"status": "live",
"verifiedAt": "2026-09-27",
"evidence": "packages/spec/src/system/book.zod.ts#matchesInclude (`Array.isArray(doc.tags) && doc.tags.includes(include.tag)` — the tag branch of `include`); packages/spec/src/system/book.zod.ts#resolveBookTree (the caller that decides membership from it); packages/rest/src/meta-item-read-gate.ts#createMetaBookTreeAnswer (`tags: d.tags` in the tree corpus the route hands `audience.readableTree(book, docs)` — the transport half); packages/rest/src/meta-item-read-gate.ts#resolveBookTree (`resolveBookTree(book, docs, book._packageId)` — where that corpus reaches the resolver)",
"evidence": "packages/spec/src/system/book.zod.ts#matchesInclude (`Array.isArray(doc.tags) && doc.tags.includes(include.tag)` — the tag branch of `include`); packages/spec/src/system/book.zod.ts#resolveBookTree (the caller that decides membership from it); packages/rest/src/meta-item-read-gate.ts#createMetaBookTreeAnswer (`tags: d.tags` in the tree corpus the route hands `audience.readableTree(book, docs)` — the transport half); packages/rest/src/meta-item-read-gate.ts#resolveBookTree (`resolveBookTree(book, docs, book._packageId, books)` — where that corpus reaches the resolver)",
"note": "DECLARED 2026-08-02 (#4509, ADR-0049) — the enforce half of enforce-or-remove. The consumer, the transport and the resolver-side interface all predated this key: matchesInclude compared `doc.tags` against a group's `include: { tag }`, the book route already forwarded `tags: d.tags`, and ResolverDoc declared `tags?: string[]` marked '(P3d; absent today)'. What was missing was one line HERE — DocSchema is strict, so authoring `tags:` was a parse error, every doc reached the resolver with tags undefined, and the `{ tag }` include variant could never match. Removing the variant was the alternative and was rejected: a union member has no clean tombstone (retiredKey covers object keys), so authors would have gotten a bare union error, and it would have discarded working matcher code. Live on arrival — the branch it feeds is reachable the moment a doc carries a tag. 2026-08-28: RE-ANCHORED (commit 8cb96ec41) and REPOINTED — `book.zod.ts:213` is `description?: string;` in the `ResolvedEntry` interface, and the parenthetical claiming it was \"matchesInclude tag branch\" named the right function ~61 lines away; the \"reached from :255 and :281\" hops were prose the ledger never bounded. `rest-server.ts:3218` had rotted onto `this.registerSecurityEndpoints(bp)` — a route-registration call, ~1,700 lines from the corpus that forwards the tags. Re-closed by hand against 93ea19bca."
}
}
Expand Down
78 changes: 78 additions & 0 deletions packages/spec/src/system/book.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,84 @@ describe('resolveBookTree — the Uncategorized group holds only the book\'s own
});
});

// [#27091] ADR-0046 §6.4 rolls up docs "left unplaced"; a doc a SIBLING book
// places is placed. Two books over one package (split by audience, §6.7) used
// to list each other's pages under *Uncategorized*, because "claimed" was
// judged against the book's own groups only.
describe('resolveBookTree — a doc a sibling book claims is not this book\'s orphan (§6.4)', () => {
const corpus: ResolverDoc[] = [
{ name: 'crm_overview', packageId: 'crm' },
{ name: 'crm_sales_leads', packageId: 'crm' },
{ name: 'crm_admin_access', packageId: 'crm' },
{ name: 'crm_admin_automation', packageId: 'crm' },
];
const withStray: ResolverDoc[] = [...corpus, { name: 'crm_stray', packageId: 'crm' }];
const userManual: Book & { packageId?: string } = {
name: 'crm_manual',
packageId: 'crm',
groups: [
{ key: 'start', label: 'Start', include: 'crm_overview' },
{ key: 'sales', label: 'Sales', include: 'crm_sales_*' },
],
};
const adminManual: Book & { packageId?: string } = {
name: 'crm_admin_manual',
packageId: 'crm',
audience: { permissionSet: 'system_admin' },
groups: [{ key: 'admin', label: 'Admin', include: 'crm_admin_*' }],
};
const books = [userManual, adminManual];
const keysAndDocs = (tree: ResolvedBook) =>
Object.fromEntries(tree.groups.map((g) => [g.key, g.entries.map((e) => e.doc)]));
const docsOf = (tree: ResolvedBook) => tree.groups.flatMap((g) => g.entries.map((e) => e.doc));

it('two books with disjoint includes over one package: neither tree has an Uncategorized group', () => {
expect(keysAndDocs(resolveBookTree(userManual, corpus, 'crm', books))).toEqual({
start: ['crm_overview'],
sales: ['crm_sales_leads'],
});
expect(keysAndDocs(resolveBookTree(adminManual, corpus, 'crm', books))).toEqual({
admin: ['crm_admin_access', 'crm_admin_automation'],
});
});

it('a doc NO book claims still rolls up under Uncategorized, in every book of its package', () => {
expect(keysAndDocs(resolveBookTree(userManual, withStray, 'crm', books)).uncategorized).toEqual(['crm_stray']);
expect(keysAndDocs(resolveBookTree(adminManual, withStray, 'crm', books)).uncategorized).toEqual(['crm_stray']);
});

it('an audience-gated sibling\'s docs never reach the other book\'s tree, and nothing appears anywhere new', () => {
const userDocs = docsOf(resolveBookTree(userManual, withStray, 'crm', books));
expect(userDocs).not.toContain('crm_admin_access');
expect(userDocs).not.toContain('crm_admin_automation');
// The catch-all only shrinks: every entry with siblings was an entry without them.
for (const book of books) {
const before = docsOf(resolveBookTree(book, withStray, 'crm'));
expect(before).toEqual(expect.arrayContaining(docsOf(resolveBookTree(book, withStray, 'crm', books))));
}
});

it('the §6.7 audience union is unmoved: the claim sets never read the catch-all', () => {
expect(Object.fromEntries(resolveDocAudiences(books, withStray))).toEqual({
crm_overview: ['org'],
crm_sales_leads: ['org'],
crm_admin_access: [{ permissionSet: 'system_admin' }],
crm_admin_automation: [{ permissionSet: 'system_admin' }],
crm_stray: ['org'],
});
for (const book of books) {
const authored = resolveBookTree(book, withStray, 'crm', books).groups.filter((g) => g.key !== 'uncategorized');
expect([...resolveBookClaimedDocs(book, withStray, 'crm')]).toEqual(authored.flatMap((g) => g.entries.map((e) => e.doc)));
}
});

it('a single-book package is byte-identical to the tree without `books`', () => {
const alone = resolveBookTree(userManual, withStray, 'crm');
expect(JSON.stringify(resolveBookTree(userManual, withStray, 'crm', [userManual]))).toBe(JSON.stringify(alone));
expect(keysAndDocs(alone).uncategorized).toEqual(['crm_admin_access', 'crm_admin_automation', 'crm_stray']);
});
});

describe('deriveImplicitPackageBook + audience', () => {
it('synthesizes a one-group book including every doc of the package', () => {
const book = deriveImplicitPackageBook('app_todo', 'Todo');
Expand Down
52 changes: 44 additions & 8 deletions packages/spec/src/system/book.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,18 +302,33 @@ function entryFromDoc(doc: ResolverDoc): ResolvedEntry {
* - Otherwise membership is derived: a doc joins the first group (in group
* order) whose `include` matches it OR whose `key` equals the doc's explicit
* `group`. Within a group, docs sort by `doc.order` then label.
* - Any doc OF THE BOOK'S PACKAGES claimed by no group falls into a
* synthetic *Uncategorized* group appended last — no doc of the book is
* ever dropped (ADR-0046 §6.4: "any doc left unplaced rolls up under a
* synthetic *Uncategorized* group", the unplaced docs being the
* - Any doc OF THE BOOK'S PACKAGES that NO registered book claims falls
* into a synthetic *Uncategorized* group appended last — no doc of the
* book is ever dropped (ADR-0046 §6.4: "any doc left unplaced rolls up
* under a synthetic *Uncategorized* group", the unplaced docs being the
* package's). The book's packages are `bookPackage` plus every group's
* `package`; a doc of any OTHER package is not this book's to catch — it
* stays reachable through its own package's book. When the book declares
* no package at all, every unclaimed doc is an orphan, as before.
* Explicit placement is NOT scoped: a doc of another package whose
* `group` names one of this book's groups still joins that group.
* - "Claimed" is judged against `books`, every registered book (#27091):
* a doc a SIBLING book places is placed, not "left unplaced" (§6.4), so
* it is not this book's orphan — two books splitting one package's docs
* (by audience, §6.7) no longer list each other's pages under
* *Uncategorized*. The judge is `resolveBookClaimedDocs` over each book,
* the very claim set `resolveDocAudiences` unions audiences over, so the
* catch-all only ever SHRINKS: a sibling's docs leave it and appear
* nowhere new. That claim set excludes the *Uncategorized* group, so the
* §6.7 audience union cannot move. Without `books` only this book's own
* groups judge, as before.
*/
export function resolveBookTree(book: Book, docs: ResolverDoc[], bookPackage?: string): ResolvedBook {
export function resolveBookTree(
book: Book,
docs: ResolverDoc[],
bookPackage?: string,
books?: readonly AudienceBook[],
): ResolvedBook {
const groupsSorted = [...book.groups]
.map((g, i) => ({ g, i }))
.sort((a, b) => (a.g.order ?? 0) - (b.g.order ?? 0) || a.i - b.i)
Expand Down Expand Up @@ -398,8 +413,9 @@ export function resolveBookTree(book: Book, docs: ResolverDoc[], bookPackage?: s
const ownPackages = [bookPackage, ...book.groups.map((g) => g.package)].filter(
(p): p is string => typeof p === 'string' && p.length > 0,
);
const claimedByAnyBook = books ? claimedByBooks(books, docs) : undefined;
const orphans = docs
.filter((d) => !claimed.has(d.name))
.filter((d) => !claimed.has(d.name) && !claimedByAnyBook?.has(d.name))
.filter((d) => ownPackages.length === 0 || ownPackages.some((p) => matchesInclude(d, '*', p)))
.sort(byOrderThenLabel);
if (orphans.length) {
Expand Down Expand Up @@ -493,6 +509,26 @@ export interface AudienceBook extends Book {
packageId?: string;
}

/**
* THE cross-book claim pass: each book with the docs it claims
* (`resolveBookClaimedDocs` over its own `packageId`). `resolveDocAudiences`
* unions audiences over it and `resolveBookTree`'s orphan pass reads its
* union ({@link claimedByBooks}), so the two never judge "claimed" apart.
*/
function* claimsPerBook(
books: readonly AudienceBook[],
docs: ResolverDoc[],
): Generator<[AudienceBook, Set<string>]> {
for (const book of books) yield [book, resolveBookClaimedDocs(book, docs, book.packageId)];
}

/** Every doc some book in `books` claims: the union of {@link claimsPerBook}. */
function claimedByBooks(books: readonly AudienceBook[], docs: ResolverDoc[]): Set<string> {
const all = new Set<string>();
for (const [, claimed] of claimsPerBook(books, docs)) for (const name of claimed) all.add(name);
return all;
}

/**
* Effective audience of every doc = the union over the books that claim it
* (ADR-0046 §6.7). A doc claimed by no authored book falls back to `'org'` —
Expand All @@ -507,9 +543,9 @@ export function resolveDocAudiences(
): Map<string, BookAudience[]> {
const audiences = new Map<string, BookAudience[]>();
for (const doc of docs) audiences.set(doc.name, []);
for (const book of books) {
for (const [book, claimed] of claimsPerBook(books, docs)) {
const audience: BookAudience = book.audience ?? 'org';
for (const name of resolveBookClaimedDocs(book, docs, book.packageId)) {
for (const name of claimed) {
audiences.get(name)?.push(audience);
}
}
Expand Down
6 changes: 3 additions & 3 deletions ui/apps/console/src/pages/BookPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,12 @@ export function DocRefusal({ name, message }: { name?: string; message?: string
export default function BookPage() {
const { slug, appName } = useParams<{ slug: string; appName?: string }>();
const { t } = useObjectTranslation();
const { books, docs, state } = useBookData();
const { books, authoredBooks, docs, state } = useBookData();

const found = useMemo(() => books.find((b) => bookSlug(b) === slug), [books, slug]);
const opensTo = useMemo(
() => (found ? firstDoc(resolveBookTree(found, docs)) : undefined),
[found, docs],
() => (found ? firstDoc(resolveBookTree(found, docs, undefined, authoredBooks)) : undefined),
[found, docs, authoredBooks],
);

if (state === 'loading') {
Expand Down
8 changes: 5 additions & 3 deletions ui/apps/console/src/pages/DocPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,14 @@ export default function DocPage() {
// reading in, so its spine renders as a persistent left nav. Resolved from
// the portal book set; degrades to no sidebar if the slug is unknown (e.g.
// the backend serves no books yet).
const { books, docs: allDocs } = useBookData();
const { books, authoredBooks, docs: allDocs } = useBookData();
const base = appName ? `/apps/${appName}/docs` : '/docs';
const resolvedBook = useMemo<ResolvedBook | null>(() => {
const book = books.find((b) => bookSlug(b) === slug);
return book ? resolveBookTree(book, allDocs) : null;
}, [books, allDocs, slug]);
// Every authored book judges the Uncategorized group, so a doc a sibling
// book places is not listed there (objectstack#27091).
return book ? resolveBookTree(book, allDocs, undefined, authoredBooks) : null;
}, [books, authoredBooks, allDocs, slug]);
const docHref = useCallback((docName: string) => `${base}/${slug}/${docName}`, [base, slug]);
// A `:slug` segment that is a book's NAME rather than its slug — what a
// `{ type: 'doc', book, doc }` navigation entry links to (objectui#11197) — is
Expand Down
Loading
Loading