Skip to content

Commit 94608a7

Browse files
fix(spec): a book tree's Uncategorized group holds only the book's own packages' unplaced docs, per ADR-0046 §6.4 (#20980) (#21076)
Fixes #20980 Clause-②: no ## What changed `resolveBookTree` (`packages/spec/src/system/book.zod.ts`) put every unclaimed doc it was handed into the synthetic *Uncategorized* group. `GET /api/v1/meta/book/:name/tree` resolves over every doc in the environment, so a book's tree listed every other package's ungrouped docs there. ADR-0046 §6.4 says the unplaced docs are the package's, and the portal (`scopeDocsToBook`) already answers per package. - **The fix (landing site: the resolver, as triage directed in `5923314533`).** The orphan pass now keeps an unclaimed doc only when it belongs to one of the book's packages: `bookPackage` plus every group's `package`. - **One reading of a doc's package.** "Belongs" is asked as `matchesInclude(d, '*', p)`: the implicit package book's own catch-all rule (`deriveImplicitPackageBook`), through the same scope test `include` uses. A doc's package is its stamped `packageId`, and a doc with none is in every scope. There is no second notion and no package lookup. - **No package declared anywhere:** unchanged. Every unclaimed doc is an orphan. - **Unchanged by construction:** derived membership, `pages` overrides and explicit `group` placement. A doc of another package whose `group` names one of the book's groups still joins it. `resolveBookClaimedDocs` already skipped the *Uncategorized* group, so the claim set, `resolveDocAudiences` and every doc's effective audience are the same as before. ## Mechanism hypotheses, measured at `2f2fa11d75` (base) and re-read at the merged head - **H1 — what a doc carries.** Both REST corpora project `packageId: d._packageId`: the tree read at `packages/rest/src/meta-item-read-gate.ts:2766` and `docCorpusOf` at `:457`. `packages/cli` `collect-docs` docs carry no `packageId`, and its one `resolveBookTree` call passes no `bookPackage` and names no group `package`, so that call is unscoped, as before. `packages/client` has no call, only a mock comment at `client.test.ts:290`. No caller declares a scope over docs it cannot place. The stop condition does not hold. - **H2 — every caller.** `readableTree` (`:608`) and `readablePages` (`:624`) pass `book._packageId`. The undeclared-name fallback `deriveImplicitPackageBook(name, name)` (`:604`) carries no `_packageId`, but its one group names `package: name`, so the implicit book is scoped too. That book used to list every other package's docs in *Uncategorized*. `resolveDocAudiences` passes `book.packageId` per book. `rest-route-ledger.ts:228` and `spec/src/api/index.ts` only name the symbols. The only verdict that moves is the tree's *Uncategorized* group. The fix site is the resolver, not the REST caller. - **H3 — the door.** I measured once through the REST tests' harness, with a scratch test that was never committed. It booted `RestServer` with a mocked protocol and read `GET /api/v1/meta/book/help_center/tree` as an `org` member. The book belongs to `crm` and has one group `start` with `include: 'crm_intro'`. The corpus was `crm_intro` and `crm_stray` (crm), `ops_keys` (ops), and `ops_placed` (ops, `group: 'start'`). - before, spec dist at the base: `200 {"start":["crm_intro","ops_placed"],"uncategorized":["ops_keys","crm_stray"]}` - after, spec dist rebuilt with the fix: `200 {"start":["crm_intro","ops_placed"],"uncategorized":["crm_stray"]}` - So another package's ungrouped doc is present before and absent after. The book's own ungrouped doc is present both times (the lit control). The cross-package explicit placement is unchanged. ## Files - `packages/spec/src/system/book.zod.ts`: the orphan pass and the resolver docblock. - `packages/spec/src/system/book.test.ts`: seven pins in a new block, and one existing pin re-judged. The pins are: - another package's ungrouped doc is out, and the book's own is in; - control: a cross-package `group` placement still joins; - a group `package` counts as one of the book's packages; - the implicit package book catches no foreign doc; - no package anywhere keeps the old answer; - an unstamped doc is in every scope; - the claim set is unchanged. - The re-judged pin is `include scoped by package ignores docs from other packages`. Its last line asserted that another package's doc *falls through* to *Uncategorized*, which is the defect. It now asserts that no *Uncategorized* group is appended. - `packages/rest/src/meta-app-nav-doc-audience.test.ts`: a file-surface addition beyond the claim, named in the report. Its assertion pinned the defect: the implicit `ops` book's tree served `crm_intro`, a `crm` doc, in *Uncategorized*. With the fix that tree serves no group, because its two pages are gated for a non-holder. The nav assertion the test is named for is unchanged. - `content/docs/ui/doc-pages.mdx`: step 4 of the membership rules said "anything claimed by nobody", which this change makes false. It now states the book's-packages rule. - `.changeset/20980-book-tree-orphans-scoped.md`: `patch` for `@objectstack/spec`. No consumer package's published files change: the rest edit is a test file. ## Tests (real runs, foreground, through `os-verify-lock.sh`) - `@objectstack/spec`: `vitest run src/system/` gave 48 files, 1706 passed, at merged head `407071df8` (spec source is unchanged since). The verbose `book.test.ts` run gave 40 passed. `typecheck`, including `check:test-typecheck` over `tsconfig.test.json`, whose `include` is `src/**/*` so it covers `book.test.ts`, exited 0. `check:generated`: all 15 generated artifacts up to date. - `@objectstack/rest` (`--project local`, full) at `07a04a571`, before the rest test edit: 250 of 251 files passed, 1 failed, 4973 passed, 114 skipped. The one failure was `meta-app-nav-doc-audience.test.ts:243`: expected `['uncategorized']`, received `[]`. That is the pin of the defect. After the edit, that file passed 19/19. At `407071df8`, `meta-app-nav-doc-audience` + `meta-doc-audience-read-fault` + `rest.test.ts` gave 3 files, 246 passed. `typecheck` exited 0. - `@objectstack/cli` (`--project unit`): 238 of 240 files passed, 3372 passed. The other 2 files (`published-subpath-console.pin`, `published-subpath-hook-body.pin`) refused with PREREQUISITE NOT MET because `packages/cli` was not built. After `pnpm --filter @objectstack/cli build` both passed, 29 tests. `collect-docs.test.ts` is among the passes. The `integration` layer was not run here; CI runs it. - `@objectstack/client`: 50 files, 641 passed. - `@objectstack/runtime` `meta-list-projection-parity.test.ts` (`--project repo`), an extra consumer of the tree route: 682 passed, every TREE cell included. - Filter direction: the dependency closures were built upstream-first with the suffix form (`pnpm --filter '@objectstack/rest^...' --filter '@objectstack/cli^...' --filter '@objectstack/client^...' build`). After the fix only `@objectstack/spec` was rebuilt. The consumer tests read the rebuilt spec `dist`, and the dist was confirmed to carry the fix before they were read. ## Reverse verification (one-off, fix committed first) `node scripts/ablation-replace.mjs` replaced the orphan scope filter (`ownPackages.length === 0 || ownPackages.some(...)`) with an always-true filter, then ran `book.test.ts`. The expected direction was red, and it went red. - The anchor hit 1 to 0. The blob went from `a3e0522bd71f` to `62c6324257f8`. - Result: 5 failed, 35 passed. The failures were the four new scoped pins and the re-judged `include scoped by package` pin. The pins whose behaviour does not move stayed green: no package declared, unstamped doc, and claim set. - The tool's restore leg: blob after restore `a3e0522bd71f`, equal to HEAD, and `git diff HEAD` empty. - Spec tests read `./book.zod` from source, so this ablation needed no `dist` rebuild. ## Gates - I derived `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` from this branch's own diff and ran its union at `da260e723`, the last commit. 104 of 105 commands exited 0. - `--ran` reconciliation: 105 derived, 104 run, 0 NOT-MEASURED, 1 UNRUN. - **NOT MEASURED: `pnpm check:type-check-debt`.** Reason: its `--re-measure` first refreshes the closure of every ledgered package with the whole-workspace turbo build, which does not fit under the foreground cap. A first attempt was killed by the runner's time cap mid-refresh. The structural half, `pnpm check:type-check-coverage`, exited 0. Invariance: no exported type moved (`check:api-surface` exit 0). The edits are a function body and TSDoc, so no ledgered package's tsc count can move from this diff. CI runs this gate after its own closure build. - `pnpm check:dual-build-cjs-loads` first refused (exit 3, nine packages had no `dist`). After those nine were built it exited 0. - ESLint, narrowed. Population: the three touched TS files, each inside `eslint.config.mjs` (`--print-config` resolves for all three). The `.mdx` and the changeset get "File ignored because no matching configuration", so they are outside the population. Count: `--format json` over the three files gave 3 files, 0 errors, 0 warnings. Invariance: `eslint.config.mjs` enables no type-aware linting (its own note at `:328`: no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. ## Acceptance notes (not filed) - `packages/rest/src/meta-item-read-gate.ts:1108` docblock: "`resolveBookTree` appends every doc the book does NOT claim as a synthetic *Uncategorized* group, so over an env-wide corpus nearly every book's tree holds some readable doc". After this change that group holds only the book's own packages' unclaimed docs. The rule it justifies still holds, since orphans are not pages, but the premise is now narrower. This is a comment, outside this claim's surface (`domain:cli`). Carrier: the next PR that touches that file. - `meta-app-nav-doc-audience.test.ts` no longer witnesses, at the REST door, a tree whose only readable entries are orphans. An implicit book now has no foreign orphan to serve. The orphans-are-not-claims half stays pinned in spec: `a doc claimed by no book defaults to org — orphans do NOT ride along with public books`. - objectui's `scopeDocsToBook` pre-filter also removes another package's doc that a `pages` override pins by name. The server's resolver still labels such a doc. This is read-only inference from objectui `main`'s `book-nav.ts`, not measured. Carrier: objectui, when its pre-filter is retired in favour of the resolver's own scoping (the card's "after that" step). --- _Generated by [Claude Code](https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b3d7a70 commit 94608a7

5 files changed

Lines changed: 123 additions & 11 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): a book tree's synthetic *Uncategorized* group holds only the unplaced docs of the book's own packages, per ADR-0046 §6.4
6+
7+
Clause-②: no
8+
9+
- `resolveBookTree` used to put every unclaimed doc it was handed into the book's *Uncategorized* group. `GET /api/v1/meta/book/:name/tree` resolves over every doc in the environment, so a book's tree listed every other package's ungrouped docs there. The docs portal never showed those docs in the book.
10+
- The group now holds a doc only when it belongs to one of the book's packages: the package that ships the book, or a package a group names with `package`. A doc with no stamped package still counts as the book's. A doc of another package stays reachable through its own package's book.
11+
- The implicit per-package book that the tree route serves for a package id catches no other package's docs either.
12+
- Unchanged: a doc whose `group` names one of the book's groups joins that group from any package. A book that declares no package keeps every unclaimed doc in *Uncategorized*. The docs a book claims, and so every doc's audience, do not change.

‎content/docs/ui/doc-pages.mdx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,11 @@ Precisely what derives it, in the order it runs:
180180
First claim wins, so a doc never appears twice.
181181
3. **Within a group, docs sort by `doc.order`, then by label** (falling back to the doc
182182
name).
183-
4. **Anything claimed by nobody is appended last** in a synthetic *Uncategorized* group.
184-
Nothing is ever dropped.
183+
4. **Any doc of the book's own packages that nobody claims is appended last** in a
184+
synthetic *Uncategorized* group, so none of the book's docs is ever dropped. The
185+
book's packages are the one that ships it plus any package a group names with
186+
`package`. A doc of another package is not this book's to catch: it appears in its own
187+
package's book. A book that names no package at all collects every unclaimed doc here.
185188

186189
So the AI-authoring property the design was built for holds: create a doc whose name
187190
matches a rule and it files itself. There is no central array to read, modify and write

‎packages/rest/src/meta-app-nav-doc-audience.test.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -237,10 +237,11 @@ describe('[#19790] a `book` entry is judged on its readable PAGES', () => {
237237
// The implicit `ops` book is `org` — the tree read opens (200)…
238238
const tree = await bookTree(rest, 'ops');
239239
expect(tree.statusCode).toBe(200);
240-
// …and serves `crm_intro` in the synthetic Uncategorized group only. The
241-
// spec calls those orphans "not an authored membership claim", so they
242-
// are not the book's pages, and the entry is still dropped.
243-
expect(tree.body.groups.map((g: any) => g.key)).toEqual(['uncategorized']);
240+
// …and serves no group at all: both its pages are gated, and
241+
// `crm_intro` is the `crm` package's doc, so it is not this book's
242+
// orphan either (#20980, ADR-0046 §6.4 — the synthetic Uncategorized
243+
// group holds only the book's own packages' unplaced docs).
244+
expect(tree.body.groups.map((g: any) => g.key)).toEqual([]);
244245
});
245246
});
246247

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

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,83 @@ describe('resolveBookTree — derived membership (the AI-safety core)', () => {
182182
{ name: 'b', packageId: 'other' },
183183
]);
184184
expect(tree.groups[0].entries.map((e) => e.doc)).toEqual(['a']);
185-
expect(tree.groups.at(-1)!.key).toBe('uncategorized'); // 'b' falls through
185+
// 'b' is another package's doc: not this book's orphan either (#20980,
186+
// ADR-0046 §6.4), so no Uncategorized group is appended for it.
187+
expect(tree.groups.map((g) => g.key)).toEqual(['g']);
188+
});
189+
});
190+
191+
// [#20980] ADR-0046 §6.4: "any doc left unplaced rolls up under a synthetic
192+
// *Uncategorized* group" — the unplaced docs are the PACKAGE's. The orphan
193+
// pass used to sweep every doc handed in, so `GET /meta/book/:name/tree`
194+
// (which resolves over an env-wide corpus) filed every other package's
195+
// ungrouped docs under this book's Uncategorized group.
196+
describe('resolveBookTree — the Uncategorized group holds only the book\'s own packages\' docs (§6.4)', () => {
197+
const book: Book = { name: 'crm_guide', groups: [{ key: 'start', label: 'Start', include: 'crm_intro' }] };
198+
const keysAndDocs = (tree: ResolvedBook) =>
199+
Object.fromEntries(tree.groups.map((g) => [g.key, g.entries.map((e) => e.doc)]));
200+
201+
it('another package\'s ungrouped doc is NOT in the book\'s Uncategorized group; the book\'s own is', () => {
202+
const tree = resolveBookTree(book, [
203+
{ name: 'crm_intro', packageId: 'crm' },
204+
{ name: 'crm_stray', packageId: 'crm' },
205+
{ name: 'ops_keys', packageId: 'ops' },
206+
], 'crm');
207+
expect(keysAndDocs(tree)).toEqual({ start: ['crm_intro'], uncategorized: ['crm_stray'] });
208+
});
209+
210+
it('control: a cross-package doc whose `group` names the book\'s group still joins it (placement is unscoped)', () => {
211+
const tree = resolveBookTree(book, [
212+
{ name: 'crm_intro', packageId: 'crm' },
213+
{ name: 'ops_placed', packageId: 'ops', group: 'start' },
214+
{ name: 'ops_keys', packageId: 'ops' },
215+
], 'crm');
216+
expect(keysAndDocs(tree)).toEqual({ start: ['crm_intro', 'ops_placed'] });
217+
});
218+
219+
it('a group\'s `package` is one of the book\'s packages: that package\'s unmatched doc is an orphan here', () => {
220+
const crossBook: Book = {
221+
name: 'crm_guide',
222+
groups: [{ key: 'ops', label: 'Operations', include: 'ops_runbook_*', package: 'ops' }],
223+
};
224+
const tree = resolveBookTree(crossBook, [
225+
{ name: 'crm_stray', packageId: 'crm' },
226+
{ name: 'ops_runbook_keys', packageId: 'ops' },
227+
{ name: 'ops_other', packageId: 'ops' },
228+
{ name: 'hr_policy', packageId: 'hr' },
229+
], 'crm');
230+
expect(keysAndDocs(tree)).toEqual({ ops: ['ops_runbook_keys'], uncategorized: ['crm_stray', 'ops_other'] });
231+
});
232+
233+
it('the implicit package book (no `bookPackage`, the tree route\'s fallback) catches no other package\'s doc', () => {
234+
const tree = resolveBookTree(deriveImplicitPackageBook('crm', 'CRM'), [
235+
{ name: 'crm_intro', packageId: 'crm' },
236+
{ name: 'ops_keys', packageId: 'ops' },
237+
]);
238+
expect(keysAndDocs(tree)).toEqual({ all: ['crm_intro'] });
239+
});
240+
241+
it('no package declared anywhere: every unclaimed doc is an orphan, as before', () => {
242+
const tree = resolveBookTree(book, [
243+
{ name: 'crm_intro', packageId: 'crm' },
244+
{ name: 'crm_stray', packageId: 'crm' },
245+
{ name: 'ops_keys', packageId: 'ops' },
246+
]);
247+
expect(keysAndDocs(tree)).toEqual({ start: ['crm_intro'], uncategorized: ['crm_stray', 'ops_keys'] });
248+
});
249+
250+
it('a doc with no stamped `packageId` is in every scope — the same reading `include` scoping uses', () => {
251+
const tree = resolveBookTree(book, [{ name: 'crm_intro', packageId: 'crm' }, { name: 'loose_note' }], 'crm');
252+
expect(keysAndDocs(tree)).toEqual({ start: ['crm_intro'], uncategorized: ['loose_note'] });
253+
});
254+
255+
it('the claim set is unchanged: neither the own nor the foreign orphan is claimed', () => {
256+
const corpus: ResolverDoc[] = [
257+
{ name: 'crm_intro', packageId: 'crm' },
258+
{ name: 'crm_stray', packageId: 'crm' },
259+
{ name: 'ops_keys', packageId: 'ops' },
260+
];
261+
expect([...resolveBookClaimedDocs(book, corpus, 'crm')]).toEqual(['crm_intro']);
186262
});
187263
});
188264

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

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -295,8 +295,16 @@ function entryFromDoc(doc: ResolverDoc): ResolvedEntry {
295295
* - Otherwise membership is derived: a doc joins the first group (in group
296296
* order) whose `include` matches it OR whose `key` equals the doc's explicit
297297
* `group`. Within a group, docs sort by `doc.order` then label.
298-
* - Any doc claimed by no group falls into a synthetic *Uncategorized* group
299-
* appended last — nothing is ever dropped.
298+
* - Any doc OF THE BOOK'S PACKAGES claimed by no group falls into a
299+
* synthetic *Uncategorized* group appended last — no doc of the book is
300+
* ever dropped (ADR-0046 §6.4: "any doc left unplaced rolls up under a
301+
* synthetic *Uncategorized* group", the unplaced docs being the
302+
* package's). The book's packages are `bookPackage` plus every group's
303+
* `package`; a doc of any OTHER package is not this book's to catch — it
304+
* stays reachable through its own package's book. When the book declares
305+
* no package at all, every unclaimed doc is an orphan, as before.
306+
* Explicit placement is NOT scoped: a doc of another package whose
307+
* `group` names one of this book's groups still joins that group.
300308
*/
301309
export function resolveBookTree(book: Book, docs: ResolverDoc[], bookPackage?: string): ResolvedBook {
302310
const groupsSorted = [...book.groups]
@@ -373,8 +381,20 @@ export function resolveBookTree(book: Book, docs: ResolverDoc[], bookPackage?: s
373381
resolvedGroups.push({ key: group.key, label: group.label, entries });
374382
}
375383

376-
// Orphans: docs claimed by no group.
377-
const orphans = docs.filter((d) => !claimed.has(d.name)).sort(byOrderThenLabel);
384+
// Orphans: docs of the book's own packages claimed by no group (ADR-0046
385+
// §6.4). "Of the book's packages" is asked through `matchesInclude` with
386+
// the catch-all rule `'*'` — the implicit package book's own rule
387+
// (`deriveImplicitPackageBook`) — so the orphan pass and `include` share
388+
// ONE reading of a doc's package: its stamped `packageId`, and a doc with
389+
// none is in every scope. No package declared ⇒ no scope, every
390+
// unclaimed doc is an orphan.
391+
const ownPackages = [bookPackage, ...book.groups.map((g) => g.package)].filter(
392+
(p): p is string => typeof p === 'string' && p.length > 0,
393+
);
394+
const orphans = docs
395+
.filter((d) => !claimed.has(d.name))
396+
.filter((d) => ownPackages.length === 0 || ownPackages.some((p) => matchesInclude(d, '*', p)))
397+
.sort(byOrderThenLabel);
378398
if (orphans.length) {
379399
resolvedGroups.push({
380400
key: UNCATEGORIZED_KEY,

0 commit comments

Comments
 (0)