Skip to content

fix(spec): a book's Uncategorized group skips docs a sibling book claims - #27262

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-oe80gc
Oct 28, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-oe80gc

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread
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.

Measure first (base d745bb5, HotCRM's two-book fixture)
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)
Suites (under os-verify-lock, after the rest^... closure build)
  • 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.
Ablation (one each, via scripts/ablation-replace.mjs, restored to the HEAD blob)
  • 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.
Derived gates

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.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufzgBbveX8mMhrSXB6VJ

resolveBookTree takes an optional fourth argument, every registered book,
and sends to the synthetic Uncategorized group only the docs of the book's
packages that no book claims (ADR-0046 §6.4: "left unplaced"). The claim
judge is resolveBookClaimedDocs over each book, the pass resolveDocAudiences
already runs, now shared through one generator. The REST book-tree route
passes the books it reads.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzgBbveX8mMhrSXB6VJ
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/spec/liveness/doc.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/liveness/doc.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 56 of 208 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 149 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0d45c0e71f0a03bdcf2230901515c2a29716a92b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b209e0326875aa4c1a63bc0e8cd14c34db03efff — the merge of head e4a5898dee9d3f85b3756d01867501bcdcd50e5c into base 0d45c0e71f0a03bdcf2230901515c2a29716a92b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b209e0326875aa4c1a63bc0e8cd14c34db03efff && git checkout b209e0326875aa4c1a63bc0e8cd14c34db03efff
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0d45c0e71f0a03bdcf2230901515c2a29716a92b e4a5898dee9d3f85b3756d01867501bcdcd50e5c && git checkout -B drift-repro 0d45c0e71f0a03bdcf2230901515c2a29716a92b && git merge --no-ff e4a5898dee9d3f85b3756d01867501bcdcd50e5c

node scripts/docs-audit/affected-docs.mjs --json 0d45c0e71f0a03bdcf2230901515c2a29716a92b

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

…ling book claims

The console's port of resolveBookTree (book-nav.ts) takes the same optional
fourth argument as the framework resolver: every authored book judges the
Uncategorized catch-all, so a doc a sibling book places is not listed there.
DocPage and BookPage pass useBookData().authoredBooks, never the portal set.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzgBbveX8mMhrSXB6VJ
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: bce0389763c9193e6bdc5356b8995fb6831f9816
Local-runs: none
Scope: packages/spec/src/system/book.zod.ts + packages/spec/liveness/doc.json, packages/rest/src/meta-item-read-gate.ts (resolveDocsAudience.readableTree, audienceBooksOf, createMetaBookTreeAnswer), packages/rest/src/meta-item-read-routes.ts and packages/runtime/src/domains/meta.ts (both transports' listTreeInput), packages/spec/src/system/book.test.ts, packages/rest/src/rest.test.ts, .changeset/27091-book-tree-sibling-claims.md, ui/packages/app-shell/src/views/metadata-admin/previews/doc-draft.ts (resolvePlacements), packages/cli/src/utils/collect-docs.test.ts, ui/apps/console/src/pages/book-nav.ts, ui/apps/console/src/pages/DocPage.tsx, ui/apps/console/src/pages/BookPage.tsx

Reviewed against GitHub (PR object, file list, net diff origin/main d745bb5e...bce03897, the head's check-runs read to completion), card #27091 (body, triage 6461914073, claim 6468704954, amendment 6470220479, os-dev-report 6470190013), ADR-0046 §6.4 and §6.7, and #20980. Not against the dev's own report. Head at review start = PR head bce03897; one commit on the branch, no later commits.

① Derived judgments

  1. Signature resolveBookTree(book, docs, bookPackage?, books?) — additive, right. One optional trailing parameter typed readonly AudienceBook[] (an interface this module already exports). No export added, none removed or renamed; every 2- and 3-argument call compiles unchanged. The api-surface snapshot (packages/spec/api-surface/system.json:863, resolveBookTree (function)) records existence only, so no regenerated artifact is owed and none is in the diff; Spec property liveness on the head is success.
  2. The orphan pass only ever shrinks — right. The sole behavioural edit is one extra conjunct on the orphan filter: !claimed.has(d.name) && !claimedByAnyBook?.has(d.name), followed by the unchanged [finding] GET /meta/book/:name/tree puts every package's ungrouped docs in the book's synthetic Uncategorized group — resolveBookTree's orphan catch-all ignores bookPackage, against ADR-0046 §6.4 and the portal's per-package answer #20980 own-packages scope test. Nothing is added to any authored group, no entry is created, no group gains a doc. An audience-gated sibling's docs leave this book's catch-all and appear nowhere new (pinned: spec test 3 asserts every doc with books was a doc without it, in both books).
  3. Without books the tree is byte-identical — right. claimedByAnyBook is undefined, undefined?.has(x) is undefined, !undefined is true, so the filter reduces to the pre-PR predicate. Spec test 5 pins JSON.stringify equality for a single-book package, with [userManual] as books and with none.
  4. The §6.7 audience union is unmoved — right, and pinned. resolveBookClaimedDocs still calls resolveBookTree(book, docs, bookPackage) with THREE arguments and skips UNCATEGORIZED_KEY, so the claim set is computed exactly as before and never reads the catch-all. resolveDocAudiences is refactored onto the claimsPerBook generator: same books, same order, same resolveBookClaimedDocs(book, docs, book.packageId) per book, same union; a pure extraction. Spec test 4 pins the full audience map over both books plus a stray doc, and that each book's claim set equals its tree's authored entries. The pre-existing resolveDocAudiences — union over claiming books (§6.7) describe is untouched.
  5. One judge, no recursion — right. resolveBookTree(…, books) → claimedByBooks → claimsPerBook → resolveBookClaimedDocs → resolveBookTree with 3 args → no claimedByBooks. Terminates; the orphan pass and the audience union read one claim set.
  6. A doc no book claims still rolls up — right. Spec test 2 (crm_stray under Uncategorized in both books); REST test (loose_note in both org_book and gated_book trees for a holder of every set).
  7. REST readableTree passes the right list — right, both transports. books is the audienceBooksOf(...) list (each book carrying packageId: b._packageId), the same list bookNamed matches on and resolveDocAudiences unions over; claimsPerBook reads book.packageId, consistent with the book._packageId the route passes as bookPackage. RestServer (meta-item-read-routes.ts:395) and the runtime dispatcher (runtime/src/domains/meta.ts:1153) both answer through createMetaBookTreeAnswer, so one edit fixes the route on both transports. The translated served book is what readableTree resolves (labels only; membership keys are untouched by translation) — unchanged by this PR.
  8. Implicit per-package book (unknown name) — unchanged. bookNamed derives a book that is not in books; its single include: '*' group scoped to the package already claims every package doc, so its catch-all was empty before and stays empty.
  9. Every in-repo caller, judged: packages/rest readableTree — passes books, fixed. packages/rest readablePages — resolveBookClaimedDocs, unchanged by design. packages/spec resolveBookClaimedDocs — 3 args by design (item 5). ui/packages/app-shell doc-draft.ts:170 resolvePlacements — 3 args; it keeps tree.groups.slice(0, book.spine.groups.length), so the catch-all never reaches its answer: unaffected, right. packages/cli/src/utils/collect-docs.test.ts:236 — single-book test, unaffected, right. packages/spec/src/system/i18n-book.test.ts:94 — 2 args, unaffected. Console port ui/apps/console/src/pages/book-nav.ts:186 — a separate implementation, called with 2 args by DocPage.tsx:165 and BookPage.tsx:52; at this head it still judges "claimed" per book, so the console sidebar keeps this card's symptom for a holder of both books, and its line-18 sentence "a faithful local port of the framework's resolveBookTree" is false at this head. The seat's amendment 6470220479 orders it fixed in this PR as a later commit, so it is recorded here as a carried item, not a FAIL of this head (see ③).
  10. Docblocks — true. resolveBookTree's docblock states the new rule (orphan = of the book's packages AND claimed by no book in books), the judge (resolveBookClaimedDocs per book), the shrink-only property, the §6.7 invariant and the no-books fallback; each sentence matches the code read. readableTree's docblock ("only the docs NO book of this audience claims") is exact for the list the route builds.
  11. Liveness doc.json — true. The tags evidence now quotes resolveBookTree(book, docs, book._packageId, books), which is line 710 of meta-item-read-gate.ts verbatim; grade unchanged (live); the Spec property liveness check-run is success.
  12. ?package= scoping — acceptable, not a defect; the body's note is incomplete. Both transports hand scopedPackageId to BOTH listTreeInput('book', …) and listTreeInput('doc', …), so under ?package=X the books of X judge X's docs, and the docs listed are X's: the answer is X's self-consistent view, and the card's target ("another registered book of the same package") is met in full. Without ?package= every book judges. Two cross-package claim shapes do not judge under a scoped read: (a) a doc of X whose explicit group names a group of a book of another package — the shape the PR body names; (b) a book of another package whose group carries package: 'X' with an include over X's docs — not named in the body. Both are pre-existing scoping semantics of the route (packages/rest 的其它 req.query.* 读取点同样把 string | string[] 当字符串用(#6307 的未扩大部分) #6877, [finding] GET /meta/book/:name/tree puts every package's ungrouped docs in the book's synthetic Uncategorized group — resolveBookTree's orphan catch-all ignores bookPackage, against ADR-0046 §6.4 and the portal's per-package answer #20980), not introduced here, and neither widens anything. Should-fix (prose only): the Acceptance note names shape (a) alone; add shape (b) in the body or the carried commit.
  13. Cost. claimedByBooks runs one resolveBookTree per registered book per tree request, now also on the allReadable fast path (previously zero audience passes there). Linear in books × docs × groups over a docs corpus; acceptable for this route, noted for the record.
  14. Test hygiene (cosmetic). rest.test.ts:797 rest.registerRoutes(); is indented two spaces inside a six-space block. Lint · ESLint on the head is success, so the lint of record accepts it; fix in the carried commit if convenient.

② Semver level

  • Changeset .changeset/27091-book-tree-sibling-claims.md: @objectstack/spec: patch, @objectstack/rest: patch. The diff moves src in exactly those two published packages (packages/spec/package.json and packages/rest/package.json, both 18.1.0, neither private); no other published package's src moves (the test, liveness and changeset files publish nothing). Every published package whose src moves is named, at the right level — right.
  • Metadata-protocol: none — true: no key, accepted value, default or meaning an author can write changes; a rendered tree's catch-all shrinks. none ⇒ patch per the Post-Task Checklist level table.
  • Clause-②: no — true: no accept set widens or narrows, no export is added or removed; the one public-surface change is an optional trailing parameter on an existing export, source- and binary-compatible for every existing call. The PR body and the changeset body both carry the two declaration lines at line start, identical.
  • No @object-ui/* changeset at this head, correctly: no ui/ file moves at bce03897. The carried console commit will owe one @object-ui/console: patch line (the amendment orders it) — a carried item, not a defect of this head.

③ Boundary flags

  • open_questions: [] in os-dev-report 6470190013 — none to answer.
  • Dev flag: console port left as is (class a). Answered in ①9. The seat's amendment 6470220479 orders book-nav.ts fixed in THIS PR, so the state at this head is recorded as carried, not a FAIL: at bce03897 the port still judges per book; DocPage.tsx:165 and BookPage.tsx:52 still call it with two arguments; useBookData().authoredBooks exists (use-book-data.ts:25) to be passed. The carried commit must: add the same optional fourth books argument to the port's resolveBookTree with the same shrink-only orphan filter; have DocPage/BookPage pass authoredBooks (never the portal set — its implicit per-package books would claim docs with no packageId); keep the port's line-18 "faithful local port" sentence true; pin it with the console's test surface (a parity pin against @objectstack/spec's resolveBookTree, as book-nav.uncategorizedScope-11340.test.ts does); add one @object-ui/console patch changeset. That commit re-opens this review on its new head.
  • Dev flag: ?package= scope note. Answered in ①12: acceptable; should-fix the body note to name the group-level package shape too.
  • Dev flag: check:dual-build-cjs-loads NOT MEASURED locally. Measured in CI by the required Build Core context (ci.yml job build-core, step run: pnpm check:dual-build-cjs-loads, after the build that produces every dist; the ci.yml paths filter lists scripts/check-dual-build-cjs-loads.mjs for that job). Conclusion on the head: Build Core completed success, so the gate is measured green in CI.
  • Dev flag: check-plugin-teardown-shape --self-test exit 3 then 0 (shallow clone lacked the pinned fixture). Covered by Lint & Repo Gates on the head: completed success (its Lint · scoped · tooling self-tests leg success).
  • Check-runs on bce03897, read to completion. Required contexts per REQUIRED_CONTEXTS in scripts/check-required-contexts.mjs on origin/main 0cd578f7 (and identically at the merge-base d745bb5e): TEN — the brief's nine plus Live E2E UI, added by ci(live-e2e): boot the workspace backend (HEAD console against HEAD backend) and make the four-spec live lane a required context — ruling 6435339772 Q1 A, re-based by #25914 letter D #27154. All 49 check-runs completed at 13:45Z; the PR head was still bce03897 at that read. Conclusions: Lint & Repo Gates success · TypeScript Type Check success · Test Core success (8/8 shards success) · Build Core success · Governed Surface Queue Guard success · Lint UI skipped — by design: ci.yml lint-ui runs only when needs.filter.outputs.ui fires and no ui/ path is in this diff (a skipped required context passes the ruleset) · Build & E2E UI success · Type Check UI success · Test UI success (aggregate; the 16-shard matrix skipped as planned) · Live E2E UI success. Every other completed check is success or a by-design skipped (Dogfood matrix, Build Docs, Temporal Conformance, the opt-in harnesses, the schedule gate); none is failure, cancelled or timed_out.
  • Author: PR user.login is objectstack-fleet[bot]; no relay-author note owed. PR assignee os-support-ai mirrors the card's.
  • Branch name: claude/project-thread-oe80gc, not claude/issue-27091-*; the claim 6468704954 records it as measured (the thread's platform-assigned branch). PR-body guards is success.
  • Governance: no governed surface in the file list (Governed Surface Queue Guard success); 183 changed lines; not a fork PR.

Implemented-by: claude/project-thread-oe80gc
Reviewed-by: session_01WGufzgbJhFoXYsLSe5WSPP

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e4a5898dee9d3f85b3756d01867501bcdcd50e5c
Local-runs: none
Scope: packages/spec/src/system/book.zod.ts + ui/apps/console/src/pages/book-nav.ts, ui/apps/console/src/pages/DocPage.tsx, ui/apps/console/src/pages/BookPage.tsx, ui/apps/console/src/pages/book-nav.siblingBooks-27091.test.tsx, ui/apps/console/src/pages/use-book-data.ts (toBook, authoredBooks, buildPortalBooks consumer), ui/apps/console/src/pages/DocsSlug.tsx (homeBook), .changeset/27091-book-tree-sibling-claims.md, packages/rest/src/rest.test.ts, packages/spec/liveness/doc.json, packages/rest/src/meta-item-read-gate.ts

Round 2 on the same brief. Round 1 (record 6470694256) passed bce03897; this round judges the delta bce03897...e4a5898d — two commits, 7f1a2b23 (console port) and e4a5898d (one indent in rest.test.ts) — plus the re-written PR body, against GitHub (PR object, file list, delta diff, the head's check-runs read to completion), card #27091 (body and its four comments, no new comment since round 1), ADR-0046 §6.4 and §6.7, #20980. Head at review start = PR head e4a5898d; no later commits.

① Derived judgments

  1. The spec half I passed is unmoved — right. The delta touches nothing under packages/spec/src, packages/spec/liveness or packages/rest/src/meta-item-read-gate.ts; its only packages/** hunk is the indent of rest.test.ts:798 (rest.registerRoutes();, two → six spaces; whitespace only). Every round-1 judgment on resolveBookTree, claimsPerBook, resolveDocAudiences, readableTree and the liveness quote stands on this head, and the Spec property liveness check-run is success.
  2. Console port book-nav.ts resolveBookTree(book, docs, bookPackage?, books?) — additive, right. Optional trailing parameter typed readonly Book[]; the five remaining in-file 2-argument calls (countBookDocs, findBookContainingDoc, claimedByBooks's own recursion-free call, the firstDoc path before BookPage's edit, tests) compile and answer as before. @object-ui/console is an app package, not a type-surface consumers import from; no api-surface artifact exists for it.
  3. Shrink-only orphan filter in the port — right. The one behavioural edit is the same extra conjunct as the spec's: !claimed.has(d.name) && !claimedByAnyBook?.has(d.name), before the unchanged [finding] GET /meta/book/:name/tree puts every package's ungrouped docs in the book's synthetic Uncategorized group — resolveBookTree's orphan catch-all ignores bookPackage, against ADR-0046 §6.4 and the portal's per-package answer #20980 own-packages scope test; nothing is added to any group. Without books, claimedByAnyBook is undefined and the predicate is the pre-PR one (the new test's CONTROL case pins the admin pages still under Uncategorized without books).
  4. claimedByBooks — a mirror of resolveBookClaimedDocs, judged acceptable, with its drift bounded by a parity pin. It is a second hand-written claim judge, but so is every line of this file: the header declares the file "a faithful local port" that must work without depending on the published @objectstack/spec version or the tree endpoint (its reason to exist), and the port already carried its own per-book claim reading in countBookDocs and findBookContainingDoc (both skip g.synthetic). The new helper follows that house shape: resolveBookTree(book, docs) per book (scope falls to bookPackage ?? book.packageId, which is what the spec's claimsPerBook reads as book.packageId), skip the synthetic group, union the doc entries. No recursion: the inner call passes no books. The one reading that differs from the spec's: the spec drops the group keyed uncategorized by KEY, the port drops the synthetic group by FLAG, so an authored group an author literally keys uncategorized is "claimed" in the port and "unclaimed" in the spec — a pre-existing divergence of the port (its countBookDocs has judged by flag since objectui#11340), at a corner no fixture exercises, and the port's reading is the stricter one. Parity pin present: test 1 of book-nav.siblingBooks-27091.test.tsx asserts, for each HotCRM book, the port's tree with authored equals @objectstack/spec/system's resolveBookTree with the same docs and the same books (shaped { name, label, groups, packageId }) — the answer the tree route serves. The two judges cannot drift apart on the fixture without that pin going red.
  5. "Faithful local port" is true again — right. The header sentence (line 18) and the docblock's "Faithful port of ADR-0046 §6.2.1" describe a resolver that now carries the same four parameters and the same orphan rule as the spec's; the docblock states the new rule, cites §6.4 and spec/books: a book's synthetic *Uncategorized* group catches same-package docs a sibling book already claims, so two books over one package each list the other's pages under Uncategorized #27091, and says to pass the AUTHORED books, never the portal set, with the reason (implicit per-package books are a reading fallback, not an authored claim). Each sentence matches the code read.
  6. DocPage.tsx:168 and BookPage.tsx:52 pass useBookData().authoredBooks — right, and never the portal set. Both destructure authoredBooks beside books, resolve the book found in the portal set (books, the slug lookup, unchanged) and judge with authoredBooks; both useMemo dependency arrays gained authoredBooks. use-book-data.ts:92 builds authoredBooks from meta.getItems('book') through toBook (stamping packageId: it._packageId ?? it.packageId), and books separately as buildPortalBooks(authoredBooks, docs). Had the portal set been passed, buildPortalBooks's implicit books (include: '*', package: p) would claim every doc of their package including ones with no packageId (matchesInclude admits an unstamped doc into every scope) — the trap the amendment named; it is avoided. The viewed book being itself implicit (no authored book for its package) is unaffected: its include: '*' group already claims every doc, so its catch-all was empty before.
  7. The port's other 2-argument callers — unaffected, right. countBookDocs (book-card doc counts) and findBookContainingDoc (homeBook, DocsSlug.tsx:114) read authored groups only (!g.synthetic), so the catch-all never reaches their answer; homeBook's fallback ownBooks[0] is unchanged. BookPage's firstDoc(resolveBookTree(found, docs, undefined, authoredBooks)) now prefers an *_index doc, else the first authored entry, over a tree without a sibling's pages in the catch-all — the right opening page, and a change only when a book has no authored docs at all.
  8. The new console test pins the HotCRM fixture — right. crm_manual + crm_admin_manual over package crm, disjoint includes, four docs: (1) neither port tree has an uncategorized group AND each equals the spec resolver's answer with the same books; (2) crm_stray (no book claims it) still rolls up; (3) CONTROL without books; (4) DocPage rendered at /docs/crm_manual/crm_overview through BookDataContext with books: buildPortalBooks(authored, docs) and authoredBooks: authored shows the Leads link, no Uncategorized text and no link to either admin page — the card's symptom pinned at the rendered sidebar, through the real page. The fixture omits the admin manual's audience (the port's BookAudience has no permissionSet form and the console sidebar does not gate — the REST layer does, round 1 ①7), which does not weaken the placement pin.
  9. Changeset — right. '@object-ui/console': patch added to the existing changeset beside @objectstack/spec and @objectstack/rest; @object-ui/console is published (18.1.0, no private), a member of the root .changeset/config.json fixed group (precedent: .changeset/25712-renderer-runs-one-sdui-parser.md), and the body gained one bullet describing the console change truthfully. Both declaration lines unchanged at line start.
  10. PR body ?package= note — now matches the code. It names both cross-package shapes that do not judge under ?package=X (a group carrying package: X with an include; a group whose key an X doc names in its explicit group), says unscoped reads judge with every book, and adds that the console reads books unscoped (use-book-data.ts:86, meta.getItems('book') with no package) so every authored book judges there — all three sentences true against meta-item-read-routes.ts:395, runtime/src/domains/meta.ts:1153 and use-book-data.ts. Round 1's should-fix is closed.
  11. Round-1 cosmetic — closed. rest.test.ts:798 is now indented with its block.
  12. Callers table in the body still lists the spec callers correctly and now states the console port's state on bce03897 and its fix in 7f1a2b23 — accurate on this head.

② Semver level

  • Changeset: @objectstack/spec: patch, @objectstack/rest: patch, @object-ui/console: patch — exactly the three published packages whose src moves on this head (packages/spec, packages/rest, ui/apps/console); no other published package's src moves (the new .test.tsx publishes nothing). Every published package named, at the right level — right.
  • Metadata-protocol: none — true (no key, value, default or meaning an author can write changes); none ⇒ patch per the Post-Task Checklist level table.
  • Clause-②: no — true: no accept set widens or narrows; no export is added or removed in a published type surface; the console port's optional parameter is on an app-internal module. Present at line start in the PR body and the changeset, identical.

③ Boundary flags

  • Round-1 CARRIED item (console port) — CLOSED on this head. Each of its five parts is met: the same optional fourth books argument with the shrink-only orphan filter (①2–3); DocPage/BookPage pass useBookData().authoredBooks, never the portal set (①6); the "faithful local port" sentence is true (①5); a console parity pin against the spec resolver plus a rendered-sidebar pin on the HotCRM fixture (①4, ①8); one @object-ui/console patch changeset line (①9).
  • Round-1 should-fix (?package= note names one shape) — CLOSED (①10). Round-1 cosmetic (indent) — CLOSED (①11).
  • Second claim judge in the port (the coordinator's question): answered in ①4 — a mirror, consistent with the file's declared port design and its existing flag-based claim reading, no recursion, drift bounded by the parity pin; the key-vs-flag reading of a group literally keyed uncategorized is a pre-existing port divergence at an unexercised corner — noted, not a defect of this head, and not a should-fix for this card.
  • open_questions: the card carries no round-2 os-dev-report; the round-1 report's list was [] and the PR body raises none.
  • Dev flag: check:dual-build-cjs-loads NOT MEASURED locally. Measured by the required Build Core context (ci.yml job build-core, step run: pnpm check:dual-build-cjs-loads): Build Core completed success, so the gate is measured green in CI.
  • Check-runs on e4a5898d, read to completion. Required contexts per REQUIRED_CONTEXTS on origin/main (ten: the brief's nine plus Live E2E UI, ci(live-e2e): boot the workspace backend (HEAD console against HEAD backend) and make the four-spec live lane a required context — ruling 6435339772 Q1 A, re-based by #25914 letter D #27154). All 68 check-runs completed at 14:48Z; the PR head was still e4a5898d at that read. Conclusions: Lint & Repo Gates success · TypeScript Type Check success · Test Core success · Build Core success · Governed Surface Queue Guard success · Lint UI success (runs this round: the diff touches ui/) · Build & E2E UI success · Type Check UI success · Test UI success · Live E2E UI success. Every other completed check is success or a by-design skipped (Check PR Size and Auto Label on a synchronize, the Dogfood matrix, Build Docs, Temporal Conformance, the opt-in harnesses, the schedule gate); none is failure, cancelled or timed_out.
  • Author: objectstack-fleet[bot]; no relay-author note owed. Assignee os-support-ai mirrors the card's. Branch claude/project-thread-oe80gc as the claim records. Not governed (Governed Surface Queue Guard success); 369 changed lines; not a fork PR.

Implemented-by: claude/project-thread-oe80gc
Reviewed-by: session_01WGufzgbJhFoXYsLSe5WSPP

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读 (席位终稿)

  • 改了什么:一个包里有两本手册(比如 HotCRM 的用户手册和管理员手册)时,每本书的「未分类」组不再收录另一本已经收录的页面。框架的 resolveBookTree、REST 书目树接口和控制台侧栏的本地移植一起改。
  • 为什么改:两本书互相把对方的页面列进「未分类」,管理员打开用户手册会看到管理员页面(hotcrm#2327)。
  • 风险与回滚:只会收缩「未分类」,读者能看到的页面不会变多;单本书的包结果逐字节不变;签名只新增一个可选参数。回滚 = revert 本 PR。
  • 先看:packages/spec/src/system/book.zod.ts 的 resolveBookTree,以及 ui/apps/console/src/pages/book-nav.ts。
  • 两分钟验证:PR 正文的验证命令;HotCRM 双书样例里,用户手册没有「未分类」组。
  • 需要你做的:无,席位落地。

席位意见:接受。契约复核两轮 PASS(6470694256、6472230364)。第一轮遗留的控制台移植在第二轮已修好,并钉了与框架答案一致的对拍测试。

席位核对 · ACCEPT · domain:spec seat 3 · os-support-ai · session session_01WGufzgbJhFoXYsLSe5WSPP · 2026-10-28T14:51Z

Head: e4a5898dee9d3f85b3756d01867501bcdcd50e5c · card #27091 · claim 6468704954 with its amendment

  1. 形态: a draft PR to main carrying Fixes #27091. The paths sit inside the amended claim:

    • spec book.zod.ts and its tests, the doc.json liveness row;
    • the REST meta-item-read-gate.ts and its test;
    • the console port book-nav.ts, DocPage.tsx and BookPage.tsx with a new test;
    • one changeset: @objectstack/spec, @objectstack/rest and @object-ui/console, all patch.

    check-governed-merges --pr 27262 reports NOT governed, so the ordinary queue landing applies. The assignee is os-support-ai.

  2. 前提实测: the premise holds on main: resolveBookTree judged "claimed" against the book's own groups only. The review confirmed:

    • The orphan pass only shrinks: one extra conjunct, and byte-identical without books (pinned).
    • The §6.7 audience union is unmoved and pinned.
    • The signature change is additive.
    • Both REST transports pass the book list, carrying its package id.
    • Round 2 confirmed the console port takes the same optional fourth argument, and the pages pass useBookData().authoredBooks, never the portal set.
    • A parity pin holds the port to @objectstack/spec/system's answer on the HotCRM fixture, and a rendered DocPage sidebar shows no Uncategorized group and no admin links.
  3. 门禁 check-runs (on this head): 68 runs, all completed at 2026-10-28T14:48Z, as the round 2 review read them. All ten required contexts are success, Lint UI included because ui/ is touched. The dev's NOT MEASURED check:dual-build-cjs-loads is a Build Core step, and Build Core passed. Dev-side readings:

    • spec: 743 of 743 files;
    • rest: 334 of 334;
    • console: 259 files and 2575 tests, plus its type-check, under the lock;
    • ablations: deleting the port guard turns 3 of 4 red, and dropping the DocPage argument turns 1 of 4 red.
  4. 接受的偏差:

    • (a) The console port keeps a private claimedByBooks that mirrors resolveBookClaimedDocs. The file is a declared port, and the parity pin bounds any drift.
    • (b) A pre-existing corner, not a defect: the spec drops the catch-all by key and the port drops it by flag. A group authored with the literal key uncategorized reads "claimed" in the port, which is the stricter reading. No case exercises it.
    • (c) Under ?package=X, books of another package do not judge X's catch-all, in either cross-package shape. The PR body states this. Unscoped reads, and the console, judge with every book.
  5. 范围外去向: none owed. The console finding the dev raised is folded in, per claim amendment 6470220479.

  6. 落地条款: the seat checked the prose faces against the diff on this head: the PR body (r1, applied by the seat and read back), the changeset, the resolveBookTree docblock and the port's "faithful local port" header. The seat readies the PR and arms auto-merge in this act, follows it to MERGED, and lands spec/books: a book's synthetic *Uncategorized* group catches same-package docs a sibling book already claims, so two books over one package each list the other's pages under Uncategorized #27091.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 28, 2026 14:52
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 28, 2026
Merged via the queue into main with commit b88519e Oct 28, 2026
69 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-oe80gc branch October 28, 2026 15:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:system size/m tests tooling

Projects

None yet

2 participants