Skip to content

Commit b88519e

Browse files
fix(spec): a book's Uncategorized group skips docs a sibling book claims (#27262)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBAcdSigfjvqX5vZGLrx11PE)_ Fixes #27091 Claim: 6468704954 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** `resolveBookTree` 的合成 *Uncategorized* 组只收「没有任何已注册 book 认领」的本包文档;`GET /meta/book/:name/tree` 把它已读到的全部 book 传进去。签名只加了一个可选的第四参数 `books`。控制台文档侧栏用的本地移植版 `book-nav.ts` 同步改成同样的规则,页面传入已编写的 book(不含合成的隐式包 book)。 - **为什么改:** 同一个包拆成两本书(如用户手册 + 按 `permissionSet` 限定的管理员手册)时,两本书的树都把对方的页面列在 *Uncategorized* 下;ADR-0046 §6.4 只汇总「未被放置」的文档,被兄弟书放置的文档不算。HotCRM 实测(hotcrm#2327)。 - **风险与回滚:** 只会从兜底组里移除条目,不会让任何文档出现在新的位置;§6.7 受众并集不变(认领集本来就排除兜底组,测试钉住)。不传 `books` 的调用方结果逐字节不变。回滚 = revert 本 PR。 - **先看:** `packages/spec/src/system/book.zod.ts`(`resolveBookTree` 孤儿过滤与新的 `claimsPerBook`)、`packages/rest/src/meta-item-read-gate.ts` 的 `readableTree`、`ui/apps/console/src/pages/book-nav.ts`(移植版同一处)。 - **两分钟验证:** `pnpm --filter @objectstack/spec exec vitest run src/system/book.test.ts -t "sibling book"` 跑出 5 条全绿;两本书互不相交的 include 下,两棵树都没有 `uncategorized` 组。 - **需要你做的:** 无,席位落地。 ## What changes - **`resolveBookTree(book, docs, bookPackage?, books?)`** (`@objectstack/spec`, additive): an optional fourth argument, every registered book. With it, the synthetic *Uncategorized* group takes only the docs of the book's packages that NO book in `books` claims. Without it, the answer is the same as before, byte for byte. The docblock states the new rule and cites ADR-0046 §6.4. - **One claim judge.** The pass `resolveDocAudiences` already ran (`resolveBookClaimedDocs` over each book with its own `packageId`) is now the private generator `claimsPerBook`. `resolveDocAudiences` unions audiences over it, and `resolveBookTree`'s orphan pass reads its union (`claimedByBooks`). No second judge was written. There is no recursion: `resolveBookClaimedDocs` calls `resolveBookTree` without `books`. - **`GET /meta/book/:name/tree`** (`@objectstack/rest`): `DocsAudience.readableTree` passes the books the audience was built over (the route's own book read, `?package=` scope included). The `readableTree` docblock says so. - **Liveness evidence**: `packages/spec/liveness/doc.json` quotes the REST call site verbatim, so its quote now reads `resolveBookTree(book, docs, book._packageId, books)`. No row changes grade. - **Console docs pages** (`@object-ui/console`): `ui/apps/console/src/pages/book-nav.ts` is the console's own port of the resolver, and its `resolveBookTree` takes the same optional fourth argument. Its private `claimedByBooks` mirrors the spec's (the authored groups' entries of each book, never the synthetic group). `DocPage` (the book sidebar) and `BookPage` (the book landing) pass `useBookData().authoredBooks`. They never pass the portal set, because its synthetic implicit per-package books are a reading fallback, not an authored claim. - **Changeset**: `@objectstack/spec` patch, `@objectstack/rest` patch, `@object-ui/console` patch, one root `.changeset/` file with both declaration lines. ## Why hotcrm#2327 measured it on HotCRM 18.2.0. The app ships `crm_manual` and `crm_admin_manual` (gated `{ permissionSet: 'system_admin' }`) over one package. Every doc is claimed by exactly one book, yet an administrator's `GET /api/v1/meta/book/crm_manual/tree` ends with an `uncategorized` group holding the admin pages, and the Admin Manual lists the user pages the same way. "Claimed" was judged against the book's own groups only. #20980 scoped the catch-all to the book's packages but did not cover a sibling book in the same package. ## Callers of `resolveBookTree` | Caller | Passes `books`? | What it sees | |---|---|---| | `packages/rest/src/meta-item-read-gate.ts` `readableTree` (the `/meta/book/:name/tree` answer, both transports) | yes | fixed | | `packages/spec` `resolveBookClaimedDocs` | no, by design | unaffected: it drops the *Uncategorized* group anyway | | `ui/packages/app-shell/.../doc-draft.ts` `resolvePlacements` (Studio doc preview) | no | unaffected: it keeps only the authored groups (cut by position), so the catch-all never reaches its answer | | `packages/cli/src/utils/collect-docs.test.ts` | no | a test of single-book behaviour, unchanged | The console's docs pages (`ui/apps/console/src/pages/DocPage.tsx`, `BookPage.tsx`) do not call the spec function. They call `book-nav.ts`'s own local port of it. On `bce03897` that port still judged "claimed" per book: with the HotCRM fixture below it answered `crm_manual` → `{"start":["crm_overview"],"sales":["crm_sales_leads"],"uncategorized":["crm_admin_access","crm_admin_automation"]}`. Its header calls it "a faithful local port", so the second commit (`7f1a2b23`, added to the claim by amendment 6470220479) gives it the same rule, and both pages pass the authored books. ## Tests - `packages/spec/src/system/book.test.ts`, a new describe block (5 tests) for a doc a sibling book claims: 1. Two books with disjoint includes over one package: neither tree has an `uncategorized` group. 2. A doc no book claims still rolls up under *Uncategorized*, in both books of its package. 3. An `audience`-gated sibling's docs never reach the other book's tree, and every entry with `books` was already an entry without it (the catch-all only shrinks). 4. The §6.7 audience union is pinned unchanged (`resolveDocAudiences` over both books plus a stray doc), and each book's claim set equals its tree's authored entries. 5. A single-book package is byte-identical (`JSON.stringify`) to the tree without `books`. - `ui/apps/console/src/pages/book-nav.siblingBooks-27091.test.tsx` (new, 4 tests), on the HotCRM fixture: 1. Neither book's port tree has an `uncategorized` group, and each equals the spec resolver's answer with the same books (what the tree route serves). 2. A doc no book claims still rolls up under *Uncategorized*. 3. Control: without `books` the port's tree is unchanged. 4. `DocPage` rendered at `/docs/crm_manual/crm_overview` shows the book's own pages, no *Uncategorized* heading, and no link to either admin page. - `packages/rest/src/rest.test.ts`: a holder of every book opens `org_book` and `gated_book` through the route. Each tree is `{ g: [own], uncategorized: ['loose_note'] }`. The comment on the existing anonymous-tree test now gives the new reason the sibling docs are absent. ## Acceptance notes - **`?package=` scope.** The tree route reads books in the same package scope as its docs, so under `?package=X` only X's books judge X's catch-all. Two shapes of a book of ANOTHER package can claim an X doc, and neither judges there: a group whose `include` matches the doc because the group carries `package: X`, and a group whose `key` an X doc names in its explicit `group`. Without `?package=`, every book judges. - **Console reads its books unscoped.** `useBookData` reads every book and doc through `meta.getItems`, so in the console every authored book judges. ## Verification Base `origin/main` `d745bb5e`; head `e4a5898d`. The spec and REST readings below were taken on `bce03897`. The second and third commits touch only the console files, the changeset and one indent in `rest.test.ts`. The console readings and the derived gates were taken on `e4a5898d`. <details><summary>Measure first (base d745bb5, HotCRM's two-book fixture)</summary> ``` crm_manual tree {"start":["crm_overview"],"sales":["crm_sales_leads"],"uncategorized":["crm_admin_access","crm_admin_automation"]} crm_admin_manual tree {"admin":["crm_admin_access","crm_admin_automation"],"uncategorized":["crm_overview","crm_sales_leads"]} audiences [["crm_overview",["org"]],["crm_sales_leads",["org"]],["crm_admin_access",[{"permissionSet":"system_admin"}]],["crm_admin_automation",[{"permissionSet":"system_admin"}]]] ``` The same script on this branch: ``` crm_manual tree {"start":["crm_overview"],"sales":["crm_sales_leads"]} crm_admin_manual tree {"admin":["crm_admin_access","crm_admin_automation"]} audiences (identical to base) ``` </details> <details><summary>Suites (under os-verify-lock, after the rest^... closure build)</summary> - `pnpm --filter @objectstack/spec test`: Test Files 743 passed (743), Tests 22268 passed, 1 todo. - `pnpm --filter @objectstack/spec typecheck`: exit 0. - `pnpm --filter @objectstack/rest test`: Test Files 334 passed (334), Tests 6560 passed, 326 skipped. - `pnpm --filter @objectstack/rest typecheck`: exit 0. - Lock: `VERDICT command-exit 0`, held 806s. - `pnpm --filter @objectstack/spec check:generated`: all 14 generated artifacts up to date. On `7f1a2b23` (console commit), one lock run: `pnpm --filter '@object-ui/console^...' build && pnpm --filter @object-ui/console test && pnpm --filter @object-ui/console type-check`. - `@object-ui/console` test: Test Files 259 passed (259), Tests 2575 passed (2575). - `@object-ui/console` type-check: exit 0. - Lock: `VERDICT command-exit 0`, held 1427s. - New file alone (`vitest run --project @object-ui/console siblingBooks-27091`): Test Files 1 passed, Tests 4 passed. </details> <details><summary>Ablation (one each, via scripts/ablation-replace.mjs, restored to the HEAD blob)</summary> - Spec: deleting ` && !claimedByAnyBook?.has(d.name)` turns 3 of the 5 new tests red (tests 1–3). Tests 4 and 5 stay green, as designed: they pin what must not move. Restored: blob == HEAD `6bb1240e6c60`. - REST: dropping `, books` from the `readableTree` call turns the new route test red. Restored: blob == HEAD `5d14f27dd89d`. - Console port: deleting ` && !claimedByAnyBook?.has(d.name)` in `book-nav.ts` turns 3 of the 4 new console tests red. The control stays green. Restored: blob == HEAD `d3a3071ca099`. - Console page: `DocPage` calling `resolveBookTree(book, allDocs)` without `authoredBooks` turns the rendered-sidebar test red (1 of 4). Restored: blob == HEAD `d41eb5b096ad`. The first try of this leg matched no test file (a path filter the vitest project did not resolve), so it measured nothing. It was re-run with a name filter. </details> <details><summary>Derived gates</summary> Round 1, on `bce03897`: 81 families. 79 exit 0 on the first run. `check-plugin-teardown-shape --self-test` exited 3 because the shallow clone lacked its pinned fixture `621a4876`; after fetching it, it exits 0 (48 cases). `check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET. Round 2, on `e4a5898d`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 84 families. The console diff adds `check-sdui-manifest` and its self-test, and `pnpm --dir ui exec vitest run scripts/` (5020 passed, 2 skipped). Reconciled with `--ran`: 84 accounted for, 83 run, 1 NOT MEASURED, 0 UNRUN, exit 0. - 83 families exit 0. - `pnpm check:dual-build-cjs-loads`: exit 3, PREREQUISITE NOT MET (it needs every package's `dist`, so a full build). NOT MEASURED locally; CI runs it. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzgBbveX8mMhrSXB6VJ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6dbff8e commit b88519e

10 files changed

Lines changed: 347 additions & 22 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/rest': patch
4+
'@object-ui/console': patch
5+
---
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
fix(spec): a book's *Uncategorized* group no longer lists the pages a sibling book places (#27091)
11+
12+
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.
13+
14+
- **`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.
15+
- **`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.
16+
- **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.
17+
18+
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.

‎packages/rest/src/meta-item-read-gate.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,9 @@ export interface DocsAudience {
396396
/**
397397
* `book` resolved over `docs`, narrowed to the entries this caller may read
398398
* — exactly the body `GET /meta/book/:name/tree` serves once the book's own
399-
* audience has admitted the caller. `canRead` is a {@link docReader} over
399+
* audience has admitted the caller. Its *Uncategorized* group holds only
400+
* the docs NO book of this audience claims (#27091), so a sibling book's
401+
* pages never land there. `canRead` is a {@link docReader} over
400402
* the same `docs` when the caller already holds one.
401403
*/
402404
readableTree(
@@ -702,7 +704,10 @@ export async function resolveDocsAudience(
702704
admitsBook: (book: Book) => audienceAllows(book?.audience, caller),
703705
docReader,
704706
readableTree: (book, docs, canRead) => {
705-
const tree = resolveBookTree(book, docs, book._packageId);
707+
// [#27091] Every book this audience was built over judges the
708+
// *Uncategorized* catch-all, so a doc a sibling book places is not
709+
// this book's orphan (ADR-0046 §6.4).
710+
const tree = resolveBookTree(book, docs, book._packageId, books);
706711
// The fast path serves the tree whole — no entry can fail an
707712
// audience every doc passes, and the empty-group drop below is
708713
// part of the narrowing, not of the tree.

‎packages/rest/src/rest.test.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -749,8 +749,9 @@ describe('RestServer', () => {
749749
const tree = res.json.mock.calls.at(-1)![0];
750750
const docsInTree = tree.groups.flatMap((g: any) => g.entries.map((e: any) => e.doc));
751751
expect(docsInTree).toContain('pub_intro');
752-
// org_intro / gated_setup are claimed by other books; the orphan pass
753-
// would render them, but the audience filter must drop them for anon.
752+
// org_intro / gated_setup are claimed by other books, so they are not
753+
// this book's orphans (#27091); the audience filter would drop them for
754+
// anon even if they were.
754755
expect(docsInTree).not.toContain('org_intro');
755756
expect(docsInTree).not.toContain('gated_setup');
756757
});
@@ -784,6 +785,27 @@ describe('RestServer', () => {
784785
expect(docsInTree).toContain('gated_setup');
785786
});
786787

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

‎packages/spec/liveness/doc.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
"tags": {
4848
"status": "live",
4949
"verifiedAt": "2026-09-27",
50-
"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)",
50+
"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)",
5151
"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."
5252
}
5353
}

‎packages/spec/src/system/book.test.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,84 @@ describe('resolveBookTree — the Uncategorized group holds only the book\'s own
262262
});
263263
});
264264

265+
// [#27091] ADR-0046 §6.4 rolls up docs "left unplaced"; a doc a SIBLING book
266+
// places is placed. Two books over one package (split by audience, §6.7) used
267+
// to list each other's pages under *Uncategorized*, because "claimed" was
268+
// judged against the book's own groups only.
269+
describe('resolveBookTree — a doc a sibling book claims is not this book\'s orphan (§6.4)', () => {
270+
const corpus: ResolverDoc[] = [
271+
{ name: 'crm_overview', packageId: 'crm' },
272+
{ name: 'crm_sales_leads', packageId: 'crm' },
273+
{ name: 'crm_admin_access', packageId: 'crm' },
274+
{ name: 'crm_admin_automation', packageId: 'crm' },
275+
];
276+
const withStray: ResolverDoc[] = [...corpus, { name: 'crm_stray', packageId: 'crm' }];
277+
const userManual: Book & { packageId?: string } = {
278+
name: 'crm_manual',
279+
packageId: 'crm',
280+
groups: [
281+
{ key: 'start', label: 'Start', include: 'crm_overview' },
282+
{ key: 'sales', label: 'Sales', include: 'crm_sales_*' },
283+
],
284+
};
285+
const adminManual: Book & { packageId?: string } = {
286+
name: 'crm_admin_manual',
287+
packageId: 'crm',
288+
audience: { permissionSet: 'system_admin' },
289+
groups: [{ key: 'admin', label: 'Admin', include: 'crm_admin_*' }],
290+
};
291+
const books = [userManual, adminManual];
292+
const keysAndDocs = (tree: ResolvedBook) =>
293+
Object.fromEntries(tree.groups.map((g) => [g.key, g.entries.map((e) => e.doc)]));
294+
const docsOf = (tree: ResolvedBook) => tree.groups.flatMap((g) => g.entries.map((e) => e.doc));
295+
296+
it('two books with disjoint includes over one package: neither tree has an Uncategorized group', () => {
297+
expect(keysAndDocs(resolveBookTree(userManual, corpus, 'crm', books))).toEqual({
298+
start: ['crm_overview'],
299+
sales: ['crm_sales_leads'],
300+
});
301+
expect(keysAndDocs(resolveBookTree(adminManual, corpus, 'crm', books))).toEqual({
302+
admin: ['crm_admin_access', 'crm_admin_automation'],
303+
});
304+
});
305+
306+
it('a doc NO book claims still rolls up under Uncategorized, in every book of its package', () => {
307+
expect(keysAndDocs(resolveBookTree(userManual, withStray, 'crm', books)).uncategorized).toEqual(['crm_stray']);
308+
expect(keysAndDocs(resolveBookTree(adminManual, withStray, 'crm', books)).uncategorized).toEqual(['crm_stray']);
309+
});
310+
311+
it('an audience-gated sibling\'s docs never reach the other book\'s tree, and nothing appears anywhere new', () => {
312+
const userDocs = docsOf(resolveBookTree(userManual, withStray, 'crm', books));
313+
expect(userDocs).not.toContain('crm_admin_access');
314+
expect(userDocs).not.toContain('crm_admin_automation');
315+
// The catch-all only shrinks: every entry with siblings was an entry without them.
316+
for (const book of books) {
317+
const before = docsOf(resolveBookTree(book, withStray, 'crm'));
318+
expect(before).toEqual(expect.arrayContaining(docsOf(resolveBookTree(book, withStray, 'crm', books))));
319+
}
320+
});
321+
322+
it('the §6.7 audience union is unmoved: the claim sets never read the catch-all', () => {
323+
expect(Object.fromEntries(resolveDocAudiences(books, withStray))).toEqual({
324+
crm_overview: ['org'],
325+
crm_sales_leads: ['org'],
326+
crm_admin_access: [{ permissionSet: 'system_admin' }],
327+
crm_admin_automation: [{ permissionSet: 'system_admin' }],
328+
crm_stray: ['org'],
329+
});
330+
for (const book of books) {
331+
const authored = resolveBookTree(book, withStray, 'crm', books).groups.filter((g) => g.key !== 'uncategorized');
332+
expect([...resolveBookClaimedDocs(book, withStray, 'crm')]).toEqual(authored.flatMap((g) => g.entries.map((e) => e.doc)));
333+
}
334+
});
335+
336+
it('a single-book package is byte-identical to the tree without `books`', () => {
337+
const alone = resolveBookTree(userManual, withStray, 'crm');
338+
expect(JSON.stringify(resolveBookTree(userManual, withStray, 'crm', [userManual]))).toBe(JSON.stringify(alone));
339+
expect(keysAndDocs(alone).uncategorized).toEqual(['crm_admin_access', 'crm_admin_automation', 'crm_stray']);
340+
});
341+
});
342+
265343
describe('deriveImplicitPackageBook + audience', () => {
266344
it('synthesizes a one-group book including every doc of the package', () => {
267345
const book = deriveImplicitPackageBook('app_todo', 'Todo');

‎packages/spec/src/system/book.zod.ts‎

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -302,18 +302,33 @@ function entryFromDoc(doc: ResolverDoc): ResolvedEntry {
302302
* - Otherwise membership is derived: a doc joins the first group (in group
303303
* order) whose `include` matches it OR whose `key` equals the doc's explicit
304304
* `group`. Within a group, docs sort by `doc.order` then label.
305-
* - Any doc OF THE BOOK'S PACKAGES claimed by no group falls into a
306-
* synthetic *Uncategorized* group appended last — no doc of the book is
307-
* ever dropped (ADR-0046 §6.4: "any doc left unplaced rolls up under a
308-
* synthetic *Uncategorized* group", the unplaced docs being the
305+
* - Any doc OF THE BOOK'S PACKAGES that NO registered book claims falls
306+
* into a synthetic *Uncategorized* group appended last — no doc of the
307+
* book is ever dropped (ADR-0046 §6.4: "any doc left unplaced rolls up
308+
* under a synthetic *Uncategorized* group", the unplaced docs being the
309309
* package's). The book's packages are `bookPackage` plus every group's
310310
* `package`; a doc of any OTHER package is not this book's to catch — it
311311
* stays reachable through its own package's book. When the book declares
312312
* no package at all, every unclaimed doc is an orphan, as before.
313313
* Explicit placement is NOT scoped: a doc of another package whose
314314
* `group` names one of this book's groups still joins that group.
315+
* - "Claimed" is judged against `books`, every registered book (#27091):
316+
* a doc a SIBLING book places is placed, not "left unplaced" (§6.4), so
317+
* it is not this book's orphan — two books splitting one package's docs
318+
* (by audience, §6.7) no longer list each other's pages under
319+
* *Uncategorized*. The judge is `resolveBookClaimedDocs` over each book,
320+
* the very claim set `resolveDocAudiences` unions audiences over, so the
321+
* catch-all only ever SHRINKS: a sibling's docs leave it and appear
322+
* nowhere new. That claim set excludes the *Uncategorized* group, so the
323+
* §6.7 audience union cannot move. Without `books` only this book's own
324+
* groups judge, as before.
315325
*/
316-
export function resolveBookTree(book: Book, docs: ResolverDoc[], bookPackage?: string): ResolvedBook {
326+
export function resolveBookTree(
327+
book: Book,
328+
docs: ResolverDoc[],
329+
bookPackage?: string,
330+
books?: readonly AudienceBook[],
331+
): ResolvedBook {
317332
const groupsSorted = [...book.groups]
318333
.map((g, i) => ({ g, i }))
319334
.sort((a, b) => (a.g.order ?? 0) - (b.g.order ?? 0) || a.i - b.i)
@@ -398,8 +413,9 @@ export function resolveBookTree(book: Book, docs: ResolverDoc[], bookPackage?: s
398413
const ownPackages = [bookPackage, ...book.groups.map((g) => g.package)].filter(
399414
(p): p is string => typeof p === 'string' && p.length > 0,
400415
);
416+
const claimedByAnyBook = books ? claimedByBooks(books, docs) : undefined;
401417
const orphans = docs
402-
.filter((d) => !claimed.has(d.name))
418+
.filter((d) => !claimed.has(d.name) && !claimedByAnyBook?.has(d.name))
403419
.filter((d) => ownPackages.length === 0 || ownPackages.some((p) => matchesInclude(d, '*', p)))
404420
.sort(byOrderThenLabel);
405421
if (orphans.length) {
@@ -493,6 +509,26 @@ export interface AudienceBook extends Book {
493509
packageId?: string;
494510
}
495511

512+
/**
513+
* THE cross-book claim pass: each book with the docs it claims
514+
* (`resolveBookClaimedDocs` over its own `packageId`). `resolveDocAudiences`
515+
* unions audiences over it and `resolveBookTree`'s orphan pass reads its
516+
* union ({@link claimedByBooks}), so the two never judge "claimed" apart.
517+
*/
518+
function* claimsPerBook(
519+
books: readonly AudienceBook[],
520+
docs: ResolverDoc[],
521+
): Generator<[AudienceBook, Set<string>]> {
522+
for (const book of books) yield [book, resolveBookClaimedDocs(book, docs, book.packageId)];
523+
}
524+
525+
/** Every doc some book in `books` claims: the union of {@link claimsPerBook}. */
526+
function claimedByBooks(books: readonly AudienceBook[], docs: ResolverDoc[]): Set<string> {
527+
const all = new Set<string>();
528+
for (const [, claimed] of claimsPerBook(books, docs)) for (const name of claimed) all.add(name);
529+
return all;
530+
}
531+
496532
/**
497533
* Effective audience of every doc = the union over the books that claim it
498534
* (ADR-0046 §6.7). A doc claimed by no authored book falls back to `'org'` —
@@ -507,9 +543,9 @@ export function resolveDocAudiences(
507543
): Map<string, BookAudience[]> {
508544
const audiences = new Map<string, BookAudience[]>();
509545
for (const doc of docs) audiences.set(doc.name, []);
510-
for (const book of books) {
546+
for (const [book, claimed] of claimsPerBook(books, docs)) {
511547
const audience: BookAudience = book.audience ?? 'org';
512-
for (const name of resolveBookClaimedDocs(book, docs, book.packageId)) {
548+
for (const name of claimed) {
513549
audiences.get(name)?.push(audience);
514550
}
515551
}

‎ui/apps/console/src/pages/BookPage.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,12 @@ export function DocRefusal({ name, message }: { name?: string; message?: string
4545
export default function BookPage() {
4646
const { slug, appName } = useParams<{ slug: string; appName?: string }>();
4747
const { t } = useObjectTranslation();
48-
const { books, docs, state } = useBookData();
48+
const { books, authoredBooks, docs, state } = useBookData();
4949

5050
const found = useMemo(() => books.find((b) => bookSlug(b) === slug), [books, slug]);
5151
const opensTo = useMemo(
52-
() => (found ? firstDoc(resolveBookTree(found, docs)) : undefined),
53-
[found, docs],
52+
() => (found ? firstDoc(resolveBookTree(found, docs, undefined, authoredBooks)) : undefined),
53+
[found, docs, authoredBooks],
5454
);
5555

5656
if (state === 'loading') {

‎ui/apps/console/src/pages/DocPage.tsx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,12 +158,14 @@ export default function DocPage() {
158158
// reading in, so its spine renders as a persistent left nav. Resolved from
159159
// the portal book set; degrades to no sidebar if the slug is unknown (e.g.
160160
// the backend serves no books yet).
161-
const { books, docs: allDocs } = useBookData();
161+
const { books, authoredBooks, docs: allDocs } = useBookData();
162162
const base = appName ? `/apps/${appName}/docs` : '/docs';
163163
const resolvedBook = useMemo<ResolvedBook | null>(() => {
164164
const book = books.find((b) => bookSlug(b) === slug);
165-
return book ? resolveBookTree(book, allDocs) : null;
166-
}, [books, allDocs, slug]);
165+
// Every authored book judges the Uncategorized group, so a doc a sibling
166+
// book places is not listed there (objectstack#27091).
167+
return book ? resolveBookTree(book, allDocs, undefined, authoredBooks) : null;
168+
}, [books, authoredBooks, allDocs, slug]);
167169
const docHref = useCallback((docName: string) => `${base}/${slug}/${docName}`, [base, slug]);
168170
// A `:slug` segment that is a book's NAME rather than its slug — what a
169171
// `{ type: 'doc', book, doc }` navigation entry links to (objectui#11197) — is

0 commit comments

Comments
 (0)