Repository navigation
Commit b88519e
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
- .changeset
- packages
- rest/src
- spec
- liveness
- src/system
- ui/apps/console/src/pages
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
396 | 396 | | |
397 | 397 | | |
398 | 398 | | |
399 | | - | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
400 | 402 | | |
401 | 403 | | |
402 | 404 | | |
| |||
702 | 704 | | |
703 | 705 | | |
704 | 706 | | |
705 | | - | |
| 707 | + | |
| 708 | + | |
| 709 | + | |
| 710 | + | |
706 | 711 | | |
707 | 712 | | |
708 | 713 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
749 | 749 | | |
750 | 750 | | |
751 | 751 | | |
752 | | - | |
753 | | - | |
| 752 | + | |
| 753 | + | |
| 754 | + | |
754 | 755 | | |
755 | 756 | | |
756 | 757 | | |
| |||
784 | 785 | | |
785 | 786 | | |
786 | 787 | | |
| 788 | + | |
| 789 | + | |
| 790 | + | |
| 791 | + | |
| 792 | + | |
| 793 | + | |
| 794 | + | |
| 795 | + | |
| 796 | + | |
| 797 | + | |
| 798 | + | |
| 799 | + | |
| 800 | + | |
| 801 | + | |
| 802 | + | |
| 803 | + | |
| 804 | + | |
| 805 | + | |
| 806 | + | |
| 807 | + | |
| 808 | + | |
787 | 809 | | |
788 | 810 | | |
789 | 811 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
50 | | - | |
| 50 | + | |
51 | 51 | | |
52 | 52 | | |
53 | 53 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
262 | 262 | | |
263 | 263 | | |
264 | 264 | | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
265 | 343 | | |
266 | 344 | | |
267 | 345 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
302 | 302 | | |
303 | 303 | | |
304 | 304 | | |
305 | | - | |
306 | | - | |
307 | | - | |
308 | | - | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
309 | 309 | | |
310 | 310 | | |
311 | 311 | | |
312 | 312 | | |
313 | 313 | | |
314 | 314 | | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
315 | 325 | | |
316 | | - | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
317 | 332 | | |
318 | 333 | | |
319 | 334 | | |
| |||
398 | 413 | | |
399 | 414 | | |
400 | 415 | | |
| 416 | + | |
401 | 417 | | |
402 | | - | |
| 418 | + | |
403 | 419 | | |
404 | 420 | | |
405 | 421 | | |
| |||
493 | 509 | | |
494 | 510 | | |
495 | 511 | | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
496 | 532 | | |
497 | 533 | | |
498 | 534 | | |
| |||
507 | 543 | | |
508 | 544 | | |
509 | 545 | | |
510 | | - | |
| 546 | + | |
511 | 547 | | |
512 | | - | |
| 548 | + | |
513 | 549 | | |
514 | 550 | | |
515 | 551 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
45 | 45 | | |
46 | 46 | | |
47 | 47 | | |
48 | | - | |
| 48 | + | |
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
52 | | - | |
53 | | - | |
| 52 | + | |
| 53 | + | |
54 | 54 | | |
55 | 55 | | |
56 | 56 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
158 | 158 | | |
159 | 159 | | |
160 | 160 | | |
161 | | - | |
| 161 | + | |
162 | 162 | | |
163 | 163 | | |
164 | 164 | | |
165 | | - | |
166 | | - | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
167 | 169 | | |
168 | 170 | | |
169 | 171 | | |
| |||
0 commit comments