Repository navigation
feat(spec): doc navigation item targeting a book and/or a doc - #19789
Conversation
Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 Co-authored-by: Claude <noreply@anthropic.com>
…dger Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 142 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 795ecedb6b133b25ba1d4604333e547c48466c7b && git checkout 795ecedb6b133b25ba1d4604333e547c48466c7b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3f9e2eaa1cadee483674449817ee732192172e7b f9baf5cb3f41e8374c433bd79d86ca57e43cb42f && git checkout -B drift-repro 3f9e2eaa1cadee483674449817ee732192172e7b && git merge --no-ff f9baf5cb3f41e8374c433bd79d86ca57e43cb42f
node scripts/docs-audit/affected-docs.mjs --json 3f9e2eaa1cadee483674449817ee732192172e7b
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Rendered by an isolated at-tier review subagent (card, rulings, the PR and its check-runs — not the dispatch order), adopted by Generated by Claude Code |
|
Maintainer reply recorded — batch #220 governed-draft row · maintainer 「批 #220 同意」 (chat, director seat summon #29, The row presented was: approve and merge PR #19789 (Tier H: ⛔ Approval and merge remain the maintainer's own clicks; no seat approves, readies, enqueues or merges a Tier H PR. Generated by Claude Code |
…lings it claimed to mirror (objectstack-ai#19855) Fixes objectstack-ai#19822 Clause-②: no `ReportSchema`'s alias table in `packages/spec/src/ui/report.zod.ts` says it is "kept deliberately parallel" to `JoinedReportBlockSchema`'s table in the same file. It was not: ten of the block's entries were missing at the top level, even though every target (`values`, `rows`, `order`, `dataset`) is a key `ReportSchema` declares. This PR adds those ten entries. After it, the top-level table carries all 17 block entries, in the block's order and wording. ## Measured first: what the author saw on `origin/main` `71ef221988` Setup: a minimal valid top-level report (`name`, `label`, `type: 'summary'`, `dataset: 'sales'`, `rows: ['stage']`, `values: ['revenue']`) plus one extra key, run through `ReportSchema.safeParse`. Each parse gave exactly one issue, `unrecognized_keys`. Each message then ended with the same `history` sentence ("Until this shape was closed these were dropped silently — …"), which is left out of the table. | key | message on `71ef221988` (before) | suggestion before | after this PR | | :-- | :-- | :-- | :-- | | `measures` | `` Unrecognized key(s) on this report: `measures`. `` | none | `` Did you mean `measures` → `values`? `` | | `metrics` | `` Unrecognized key(s) on this report: `metrics`. `` | none | `` Did you mean `metrics` → `values`? `` | | `dimensions` | `` Unrecognized key(s) on this report: `dimensions`. `` | none | `` Did you mean `dimensions` → `rows`? `` | | `groupBy` | `` Unrecognized key(s) on this report: `groupBy`. `` | none | `` Did you mean `groupBy` → `rows`? `` | | `groupings` | `` Unrecognized key(s) on this report: `groupings`. `` | none | `` Did you mean `groupings` → `rows`? `` | | `sort` | `` Unrecognized key(s) on this report: `sort`. `` | none | `` Did you mean `sort` → `order`? `` | | `orderBy` | `` Unrecognized key(s) on this report: `orderBy`. Did you mean `orderBy` → `order`? `` | `order` (edit distance), **correct** | `` Did you mean `orderBy` → `order`? `` (now from the table) | | `sortBy` | `` Unrecognized key(s) on this report: `sortBy`. `` | none | `` Did you mean `sortBy` → `order`? `` | | `objectName` | `` Unrecognized key(s) on this report: `objectName`. `` | none | `` Did you mean `objectName` → `dataset`? `` | | `object` | `` Unrecognized key(s) on this report: `object`. `` | none | `` Did you mean `object` → `dataset`? `` | **Wrong suggestions: none.** No key drew a suggestion pointing at the wrong key. In particular, `objectName` did **not** suggest `name`. Nine of the ten got no suggestion at all. `orderBy` alone reached its target, by edit distance, and that target was correct. As a control, the same ten keys on a `JoinedReportBlockSchema` in the same run each got the block table's prescription. ## What changed - **`report.zod.ts`: `ReportSchema`'s `aliases`.** Added `objectName`/`object` → `dataset`, `measures`/`metrics` → `values`, `groupings`/`groupBy`/`dimensions` → `rows`, and `sort`/`orderBy`/`sortBy` → `order`. They sit in the block table's order, under the block's comments, adapted from "block" to "report". - **The history note stays true.** The "kept deliberately parallel" paragraph now says what parallel means: every block entry whose target this schema declares is carried here to the same target. A key that must not route at the top level gets a `guidance` line. The note also names where the two tables still differ, and why: the `guidance` tables. The block's three guidance entries point at keys this schema declares, and `drillDown` is report-only. The existing note about the three removed entries (`columns`, `chart`, `chartConfig`) is unchanged and still accurate. A new paragraph records that the ten were missing and that only `orderBy` reached its target by distance. - **No `guidance` entry was needed.** None of the ten has a reason not to route at the top level. Each target is declared on `ReportSchema`, and none of the ten keys is declared there. One path was checked end to end: on a `type: 'joined'` report, `sort:` is told `order`, and writing `order` there is refused by the existing refinement ("a `joined` report orders per block — move `order` onto `blocks[]`"). So that chain ends with a prescription, never with silence. The comment above the ordering entries says so. ## Tests `packages/spec/src/ui/report.test.ts` has a new `describe`, "ReportSchema — routes the block vocabulary the way a block does": - **Per key, ×10: what the author sees.** The parse fails, the one `unrecognized_keys` issue names exactly that key, and the message contains `` Did you mean `KEY` → `TARGET`? ``. - **Per key, ×10: taking the advice parses.** A full green `safeParse` of the valid report with the target key. These are preservation pins and are green before and after this change. - **Parity, derived at runtime.** The pin reads both tables from `strictObjectDeclarations()`, the same registry `alias-integrity.test.ts` uses, looked up by `surface`, with exactly one declaration asserted per surface. Every block alias whose target is in `ReportSchema`'s shape must be routed at the top level to the same target, or be answered by a top-level `guidance` entry. A non-vacuity floor asserts that the derived set contains all ten keys this card names. **Firing control.** Run at `8a688435cc`: the alias region was swapped back to `71ef221988`'s with `node scripts/ablation-replace.mjs`, which wraps the run and restores the file. The on-disk blob during the mutation was `fe9d289f63ba`, which is exactly `71ef221988`'s blob for `report.zod.ts`, so the test ran against the file as it is on main. Result: `Tests 10 failed | 40 passed (50)`. - The per-key pin went red for 9 of the 10 keys. - The per-key pin for `orderBy` stayed green, because edit distance already answered `order`, as measured above. Its table entry is held by the parity pin. - The parity pin went red with exactly the ten rows, for example `measures → values (top level: absent)` and `orderBy → order (top level: absent)`. Restore was proven: the blob after restore equals `HEAD`'s `452f138f054d`, and `git diff HEAD` is empty. **Runs.** - Head `926cff321d`: `@objectstack/spec` `report.test.ts` + `alias-integrity.test.ts` + `strictness-batch14.test.ts` gave `3 passed (3)`, `91 passed (91)`. At `c78242fe05` the same set plus `strict-object.test.ts` gave `4 passed (4)`, `123 passed (123)`. - Head `926cff321d`: `pnpm --filter @objectstack/platform-objects test`, run after rebuilding spec and its closure, gave `54 passed (54)` files and `883 passed (883)` tests. `pnpm --filter @objectstack/platform-objects typecheck` exits 0. - Full `@objectstack/spec` `local` project at `8a688435cc`: `523 passed (523)` files, `15434 passed | 1 todo` tests. `packages/` is byte-identical between `8a688435cc` and `c78242fe05`: `git diff --quiet` exit 0. - `pnpm --filter @objectstack/spec typecheck`: exit 0. `check:test-typecheck` reports OK and holds the ledger unchanged, and `tsc -p tsconfig.test.json --listFilesOnly` includes `src/ui/report.test.ts`. **`alias-integrity.test.ts`** is not edited; PR objectstack-ai#19789 holds that file. Run as-is: `20 passed (20)` at `8a688435cc` and again at `c78242fe05`, inside the 123 above. Its three claims hold for the new entries. No key is declared, every target is accepted, and no two keys in the table collapse onto one probe. **Reading pin reversed in `@objectstack/platform-objects`.** `packages/platform-objects/src/apps/translations/report-form-echo-decisions.test.ts` pinned the gap this PR closes as a reading: it asserted that `measures` on a top-level report drew no rename suggestion. That turned `Test Core (4/6)` red on `c78242fe05`. The test is reversed in place, not deleted. Under a retitled `it`, it now asserts that the top-level report refuses `measures` AND names `` `measures` → `values` ``; the block half is unchanged. The file header bullet that described the asymmetry is rewritten to match. No other test in that file changed. ## Generated artifacts `pnpm --filter @objectstack/spec build` runs `gen:schema`. After it, `git status --porcelain` was empty: no generated file moved, as the claim predicted. Alias keys are not part of `authorable-surface/`. `pnpm --filter @objectstack/spec check:generated` then reported `All 15 generated artifacts are up to date`, measured against the dist that build produced (declaration stamp match). ## Changeset reading The change ships, so it carries a `patch` changeset (`.changeset/19822-report-top-level-aliases.md`), with `Clause-②: no`. - `@objectstack/spec`'s `files[]` includes `dist` and `src/**/*.zod.ts`, so `report.zod.ts` is published both as source and compiled into `dist/ui/`. - Positive control: `measures: 'values'` occurs once in `71ef221988`'s `report.zod.ts` (block only) and twice at head, and the built `dist/ui/index.js` and `index.mjs` each carry it twice. - The change is a behaviour change in a released package (`17.4.0`), namely the text of a rejection. AGENTS.md makes that a `patch`, and ⛔ never `skip-changeset`. - It is not breaking, since every accept/reject verdict is unchanged. Only the prescription inside an existing `unrecognized_keys` message is new. `check-adr-0087-registration` reads it as `1 non-breaking changeset(s) seen`. ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` from this worktree: 85 commands for the 4-path change set, the first round's 83 plus `check:i18n` and `check:i18n-stale-fill`. All 85 were run at head `926cff321d` after a full package build. `dispatch-gates --ran` read: `85 derived, 85 run, 0 NOT-MEASURED, 0 UNRUN`, with every recorded exit code 0. That union covers `check:*` families only. Package test suites run in CI's `Test Core`, which the tool lists as a path-scheduled CI job outside its derived total. - Three families needed built packages that a fresh worktree lacks, and first answered exit 3: `check:dual-build-cjs-loads`, `check:type-check-debt` and `check:doc-formula-expressions`. - The package closure was then built, with declarations, under the shared verify lock, and all three were re-run on the same head, each exiting 0. The `type-check-debt` verdict line was "4 ledger entr(ies) re-measured … none above its recorded number". Lint, as a proven narrowing rather than a repo scan: - The three changed source files were linted with `eslint --no-inline-config --format json` at `926cff321d`: 3 files, 0 errors, 0 warnings. - The config applied to them (`--print-config`) has no `parserOptions.project`, and `eslint.config.mjs` states that it never enables type-aware linting. So this diff cannot move the lint verdict of any untouched file. - The changeset is not an eslint input. ## Acceptance notes - **Out of scope, reported for a separate card, not touched here.** A `type: 'joined'` report accepts top-level `dataset`, `rows`, `columns` and `values`. For example, `{ type: 'joined', blocks: [...], dataset: 'sales', rows: ['stage'], columns: ['region'], values: ['revenue'] }` parses clean at head. But the report's own contract says "For a `joined` report, the data lives on `blocks`", and the renderer's joined branch returns before it reads any of them (objectui `0cf2d66`, `packages/plugin-report/src/DatasetReportRenderer.tsx`, the `presentation === 'joined'` branch). So those keys are silently dropped. `order` is the only top-level selection key the joined refinement refuses. This predates this PR: the existing `fields → values` and `dataSet → dataset` entries already pointed there, and so do the new `measures`, `dimensions` and `objectName` routes on a joined report. The loud fix is to extend the joined arm of the refinement the way `order` already is. That narrows acceptance, so it needs its own card and changeset. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…er rule (objectstack-ai#19869) Fixes objectstack-ai#19831 Clause-②: no `manifest.namespace` is enforced by `^[a-z][a-z0-9_]{1,19}$`, so its first character must be a lowercase letter. The refusal sentence and the TSDoc `Rules:` line only stated the length and the charset. `1leave` and `_leave` satisfied every clause an author was shown, and the regex still refused them. This PR makes both texts state what the regex enforces. The regex is byte-identical, so the accept set does not move. ## What changes | surface | before | after | |:--|:--|:--| | `ManifestSchema.namespace` refusal (`packages/spec/src/kernel/manifest.zod.ts`) | `Namespace must be 2-20 chars, lowercase alphanumeric + underscore` | `Namespace must be 2-20 chars, start with a lowercase letter, and contain only lowercase letters, digits and underscores` | | same key, TSDoc `Rules:` line | `2-20 characters, lowercase letters, digits, and underscores only.` | `2-20 characters, starting with a lowercase letter; lowercase letters, digits, and underscores only (1leave and _leave are refused).` | | `PackageSchema.namespace` refusal (`packages/spec/src/marketplace/package.zod.ts`). Through it, `CreatePackageRequestSchema.namespace` and the scaffold-only `TemplateManifestSchema.namespace` too | byte-identical copy of the old sentence | the new sentence, byte-identical to the manifest's | The `package.zod.ts` copy is the second author-facing restatement. The step-1 sweep below found it in `packages/spec`, triage's grading named it, and the dispatch authorised fixing it here. If only one copy changed, the manifest and the publish payload would prescribe two different rules for one vocabulary. Its in-code comment now says that the sentence is kept identical as well as the pattern. The new sentence has no trailing period on purpose. `duplicatePackage` in `metadata-protocol` appends `. ` after the declaration's message (`protocol.ts`, the explicit-`targetNamespace` and derived-namespace refusals). ## Measured, before and after Each value was parsed through `ManifestSchema.shape.namespace` from source: `origin/main` `0e90a8d1c5` before, this branch `27704f4cc2` after. | value | before | after | |:--|:--|:--| | `1leave` | refused, old sentence | refused, new sentence | | `_leave` | refused, old sentence | refused, new sentence | | `a` (1 char) | refused, old sentence | refused, new sentence | | 21-char name | refused, old sentence | refused, new sentence | | `Leave` | refused, old sentence | refused, new sentence | | `leave`, `ab`, 20-char name | accepted | accepted | All refusals are one issue with code `invalid_format`. The verdicts are identical on both sides. Only the text changed. ## Restatement census (step 1) This is `git grep` over the whole tree at `0e90a8d1c5`, for the regex literal, the sentence, `2-20 chars`, `2-20 characters` and `alphanumeric + underscore`. CHANGELOGs and release pages are excluded. | site | states the rule completely? | action | |:--|:--|:--| | `packages/spec/src/kernel/manifest.zod.ts` refusal + TSDoc | no (the leading letter is missing) | fixed here | | `packages/spec/src/marketplace/package.zod.ts` refusal | no (same sentence) | fixed here | | `packages/spec/src/api/endpoint-publish-gate.ts` refusal (`apis:` without a legal namespace) | yes, it quotes the regex | none | | `packages/spec/src/kernel/namespace-prefix.ts` code comment `(2-20 chars)` | partial, but it is an internal comment directly above the regex literal and not author-facing | none | | `packages/cli/src/commands/package/publish.ts` refusal + docblock | yes ("a lowercase letter followed by lowercase letters, digits or underscores") | outside `packages/spec`, none | | `packages/cli/src/commands/init.ts` docblock | yes, it quotes the regex | outside `packages/spec`, none | | `packages/create-objectstack/src/index.ts` docblock | yes, it quotes the regex | outside `packages/spec`, none | | `content/docs/api/declarative-endpoints.mdx` | yes, it quotes the regex | outside `packages/spec`, none | | `docs/adr/0048-…`, `docs/adr/0121-…` | yes, they quote the regex | outside `packages/spec`, none | | `.changeset/19577-duplicate-package-explicit-namespace.md` (unreleased) | it quotes the OLD sentence verbatim as `duplicatePackage`'s refusal text | outside `packages/spec`, not edited. See Acceptance notes | | `skills/**` | no restatement of the charset rule | none | | `content/docs/references/**` (generated) | renders the `.describe()` text only, which states no rule | none | ## Pins of the old sentence (step 4) This is `git grep` over all of `packages/` (and `examples/`, `apps/`, `scripts/`) for the sentence and its fragments. - `packages/spec/src/marketplace/package-namespace.test.ts` pinned it verbatim twice (`PackageSchema` and `TemplateManifestSchema` refusals). Both now pin the new text. - `packages/metadata-protocol/src/protocol.duplicate-package-target-namespace.test.ts` reads the sentence from `ManifestSchema.shape.namespace` at run time and never retypes it. No edit was needed, and it passes against the rebuilt spec. - Zero other hits. The sibling `objectui` (`origin/main` `544ecba`) has zero hits too, with the control word `namespace` hitting. ## Generated artifacts (step 5) `pnpm --filter @objectstack/spec build` then `check:generated`: all 15 generated artifacts are up to date, and nothing was regenerated or committed. The `.describe()` text is unchanged, so `content/docs/references/kernel/manifest.mdx` does not move. The built `dist/` carries the new sentence in 18 files and the old one in 0. ## Changeset (step 6) `.changeset/manifest-namespace-leading-letter-refusal.md` is `"@objectstack/spec": patch`. The reading: - AGENTS.md Post-Task Checklist step 3 says a bug fix in a released package takes a `patch`. - The shipped text of `@objectstack/spec` changes: the built `dist/` carries the new sentence, so `skip-changeset` does not apply. - `Clause-②: no`: no authorable key is added, removed, widened or narrowed, and the regex and accept set are byte-identical. - `@objectstack/metadata-protocol` ships no changed bytes, because it reads the message at run time. So it has no changeset entry. ## Tests All of these ran at `27704f4cc2` unless another commit is noted. - New pins in `packages/spec/src/kernel/manifest.test.ts`: `1leave` and `_leave` are refused as exactly one `invalid_format` issue, and the message matches `start with a lowercase letter`. The same sentence still names `2-20 chars` and the charset. As a control, `leave`, `crm`, `my_app_2`, `a1` and a 20-char name still parse, both on the field and in a whole manifest. - New pin in `package-namespace.test.ts` ("two gates, one vocabulary"): all four namespace fields refuse `1leave` with the manifest's exact sentence. The existing verdict table could not see the prose, and this pin closes that class of drift. - `pnpm --filter @objectstack/spec test`: 525 files passed, 15503 tests passed, 1 todo. - `pnpm --filter @objectstack/spec typecheck` (tsc + scripts + `check:test-typecheck`): exit 0, and the debt ledger is unchanged. - `@objectstack/metadata-protocol`: built its dependency closure, then `pnpm --filter @objectstack/metadata-protocol test` ran 188 files passed and 3 skipped, and 2673 tests passed and 19 skipped. The verbose run of `protocol.duplicate-package-target-namespace.test.ts` passed 13/13. - **Firing control**, at `48ea3d9960` (same `packages/spec` tree): `scripts/ablation-replace.mjs` put today's sentence back through an anchor that must hit, with a shell trap restoring from `HEAD`. - Leg 1 changed `manifest.zod.ts` only. It went red, 4 failed / 85 passed: both leading-letter pins, the charset pin, and the four-field sentence pin. - Leg 2 changed `package.zod.ts` only (drift between the two declarations). It went red, 3 failed / 86 passed: the four-field sentence pin and both verbatim pins. - Restored, it went back to green, 89/89. The restore was proven by blob hash equal to `HEAD` and an empty `git diff HEAD` for both files. Nothing was left in the tree. - Lint was narrowed to the 4 touched `.ts` files: `eslint --no-inline-config --format json`, 4 files reported, 0 errors, 0 warnings. The population comes from eslint's own `isPathIgnored` (false for all 4). The config never enables type-aware linting (no `parserOptions.project`), so this diff cannot change a verdict on an untouched file. ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `27704f4cc2` derived 83 commands. All 83 ran, and `--ran` reconciles them as 83 run, 0 NOT-MEASURED, 0 UNRUN. Three gates first exited 3 (PREREQUISITE NOT MET, workspace `dist/` absent): `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt`. After `turbo run build --filter='./packages/*' --filter='./packages/*/*'` all three exit 0. Six roster gates in directories this diff touches also exit 0: `check-changeset-fixed`, `check:meta-url-spelling`, `check:spec-changes`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`. The spec `repo` vitest project and the CI-only jobs are left to CI. This branch is based on `0e90a8d1c5`. `origin/main` is 4 commits ahead (`2bbb462335`). None of them touches a file in this diff, and their diff has zero hits for the sentence. ## Acceptance notes - **Sibling key, same class, not fixed here.** The dispatch rules out every other `ManifestSchema` key. `manifest.id` / `PackageSchema.manifestId` (`manifestIdRefusal`, same file) say "lowercase dot-separated segments; hyphens allowed inside a segment, underscores are not.", but the pattern also requires each segment to open with a letter. Measured at `0e90a8d1c5`: `com.example.2app` and `com.2example.app` are refused with exactly that sentence and no suggestion. They satisfy every clause it states. This is reported for the seat to file. - **The unreleased changeset `.changeset/19577-duplicate-package-explicit-namespace.md` (line 15)** quotes the old sentence as the text `duplicatePackage` returns. After this PR that quote describes text the runtime no longer produces. It is outside `packages/spec`, so it is not edited here. This PR's own changeset states the new sentence and names `duplicatePackage`, so the same release carries the correction. The replacement for the quoted fragment is `Namespace must be 2-20 chars, start with a lowercase letter, and contain only lowercase letters, digits and underscores`. - PR objectstack-ai#19637 edits the `version` key and the imports of `manifest.zod.ts`, and PR objectstack-ai#19789 regenerates `manifest.mdx`. This PR touches neither region and moves no generated file. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19482
Clause-②: yes
The spec half of the ruling on #19482 (letter A, with the book/doc addendum): one
docnavigation item variant that opens abookand/or adoc, with at least one required. The objectui half (renderer + editor) is objectui#10188 and is not touched here.What changed
DocNavItemSchema(packages/spec/src/ui/app.zod.ts) sits with the other nav variants and is strict in the same way (strictObject(navItemSurface('doc'), …)). It is a member of theNavigationItemSchemadiscriminated union and of both hand-tied types (NavigationItem,NavigationItemInput). New exports:DocNavItemSchema,DocNavItem,DocNavItemParsed.book?: a declared book name, or the package id for the package's implicit book.doc?: a doc name (snake_case filename stem, socrm_lead_guide.mdis refused)..refine(requiredOneOf(['book', 'doc'])): an item with neither is refused, and the message names both keys..describe()states the audience rule from the addendum. Abookentry shows the member's pruned subset of pages and is hidden from a member with no readable page. Adocentry the member may not read is hidden.visible/requiredPermissionscan only narrow further.docName→docandbookName→book.book/docwritten on another variant points attype: 'doc', and the two prose targets are added toPROSE_ALIAS_TARGETS.docs/nav-targetinpackages/cli/src/utils/collect-docs.ts(lintDocNavTargets, called fromcollectAndLintDocs, so it runs inos build,os validateandos lint). It refuses with a did-you-mean and a remedy. It walks appnavigation,areas[].navigation(children included) andmanifest.navigationContributionsitems.content/docs/ui/apps.mdxgains a Doc Navigation section, and theapp navigation itemrow inpackages/spec/variant-docs.jsonis re-keyed to includedoc(check:variant-docs).skills/objectstack-ui/SKILL.mdgains adocrow, whichcheck:skill-identifier-livenessrequires (see "Governed surface" below).check:generated --fix, never by hand):authorable-surface/ui.json,api-surface/ui.json,export-origins/ui.json,declaration-map/ui.json,json-schema.manifest/ui.json,content/docs/references/**, and the strictness-ledger counts..changeset/19482-doc-nav-item.md:@objectstack/specminor,@objectstack/climinor.Landing site of the reference check, and why it is not
validateCrossReferencesThe dispatch assumed the check would go beside the sibling nav-target checks (
objectName/dashboardName/pageName/reportName) invalidateCrossReferences,packages/spec/src/stack.zod.ts. I measured that site and it cannot see docs. Docs join the artifact fromsrc/docs/*.mdincollectAndLintDocs, after the config module (and sodefineStack) has already run. For example,examples/app-showcaseauthorsbooksinline indefineStackbut has no inlinedocs: its nine docs live insrc/docs/. A doc arm invalidateCrossReferenceswould therefore do one of two wrong things. Size-gated like its siblings, it would never fire. Ungated, it would refuse a doc that the build is about to collect. The CLI docs step is the one place that holds the complete doc set (inline docs, docs read off disk, and every package's docs). It already hosts the reverse-direction check (docs/metadata-embed-ref). Both targets of one item are judged there, so the rule has a single site.JSON Schema: refine, not a union — the PM assumption is falsified
The "at least one of" rule is written as
.refine(requiredOneOf(['book', 'doc'])).requiredOneOfis one of the closed, projectable refinements from #18670 item 2 (src/shared/refinement-projection.ts), so the generator publishes it. Measured:json-schema/ui/DocNavItem.jsoncarries anallOfofanyOf: [{required:[book]}, {required:[doc]}], and the new test pins the projection. With this, the published schema is no wider than zod. A union of three shapes would have given the same JSON Schema. It would also have replaced the single remedy message with aninvalid_unionaggregate, and it would have split the per-variant strict surface into three.The
allOfentry appears twice. That is a pre-existing generator quirk:automation/AssignmentExpressionValue.jsondoes the same. The two entries are identical, so the result is unchanged. See the Acceptance notes.The ruling's three confidence gaps — first readings
bookruntime overlay in objectui is not measurable from this repo. There is no objectui checkout in this container (.objectui-sha=87af769e). Backend side, measured: thebookregistry row isallowOrgOverride: false(packages/spec/src/kernel/metadata-plugin.zod.ts:1080). That value was rolled back from ADR-0046 §6.4'strue(ADR-0005:57 白名单表与注册表的分歧不止 flow:page/app/action/permission/tool/skill 六类 ADR 写 ❌ 而 allowOrgOverride:true,另有 dataset/book/position 三类表里没有却默认成了 true #6483). A Studio drag-edit of a PACKAGED book answers403 not_overridable, and new runtime books still work (allowRuntimeCreate: true)./meta/doccontent limits, measured: none at the schema, and none on the save path.DocSchema.contentis an unboundedz.string()(packages/spec/src/system/doc.zod.ts). The MDX and image bans run only in the build lint (lintDocsincollect-docs.ts), not onPUT /meta/doc. A grep for a body limit (bodyLimit,maxBody,PAYLOAD_TOO_LARGE) finds none on the metadata routes; the only hit ispackages/rest/src/import-prepare.ts./meta/doc,/meta/bookand/meta/book/:name/treeapply the ADR-0046 §6.7 audience gate (packages/rest/src/rest-server.ts, the book tree route and the list gates). The server-side app-nav filter (filterAppForUserWithReason) prunes only onrequiredPermissions,requiresServiceand object servability. It has no audience arm. So the server still sends adocentry to a member who may not read the doc, and hiding it is up to the renderer (objectui#10188). Clicking it hits the gated read (401/403). This PR does not add an audience arm to that filter. That question is raised in the report for the seat.Governed surface: this PR is Tier H
skills/objectstack-ui/SKILL.mdis askills/**path, which makes the whole PR Tier H.check:skill-identifier-livenessholds the Navigation Item Types table exhaustive overNavItemVariant. Adding the variant without its row reds that gate, and the other remedy (--updateof the gap ledger) is maintainer-only.Readings:
skills/objectstack-ui/SKILL.mdlinesskills/*/SKILL.mdlinesskills/objectstack-ui/SKILL.mdtokens (ceiling 3856)The new row's tokens are paid for by deleting text in the same file:
Target precedence: recordId → filters → viewNamewas wrong. The spec refusesfilterswithrecordIdorviewNameand states no precedence (objectNavTargetExclusivity).(type: 'home' | 'list' | ...)aside is gone; page types are covered inrules/pages.md.paramsbecome props clause is gone.Tests (at
f9baf5cb3f)pnpm --filter @objectstack/spec test: 517 files, 15077 passed, 1 todo.pnpm --filter @objectstack/spec typecheck: exit 0 (check:test-typecheck: OK).pnpm --filter @objectstack/cli exec vitest run --project unitovercollect-docs.nav-target.test.ts,collect-docs.test.tsandcollect-docs.package-docs.test.ts: 3 files, 88 passed.pnpm --filter @objectstack/cli typecheck: exit 0. The integration tier is left to CI: no spawn entry or integration-tier file is touched.src/ui/app-doc-nav-item.test.ts:customissue at['navigation', 0], message names both keys) on the union mount and on the standalone export;docis refused, and so is an emptybook;collect-docs.nav-target.test.ts: unknown doc and unknown book are refused with a did-you-mean and the remedy. Also covered: the implicit book (package id), package-body books, group / area / contribution walks, and a realsrc/docs/*.mddoc that resolves throughcollectAndLintDocs.app-strictness-batch19.test.ts(nine exported branches),app.test.ts("accepts every variant"),alias-integrity.test.ts(ten nav tables, two prose targets),app.nav-type-assertions.ts(doc probes, one@ts-expect-error).node scripts/ablation-replace.mjs --file …/app.zod.ts --anchor "}).refine(requiredOneOf(['book', 'doc']), {" --replacement "}).refine(() => true, {" -- vitest run src/ui/app-doc-nav-item.test.ts.7ac11bf63025andgit diff HEADis empty../app.zodfrom source.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 117 families atf9baf5cb3f. All 117 were run on that head and all exited 0.--ranreconciles "117 run, 0 NOT-MEASURED (a DERIVED zero — all 117 recorded an exit code and none of them is 3)".check:variant-docs(ledger key + apps.mdx),check:skill-identifier-liveness(SKILL.md row),check-skills-token-ratchet(paid by deletion).check:skill-examples,check:i18n*,check:dual-build-cjs-loads.npx eslint --no-inline-configover the five touched TypeScript files: exit 0. This is a targeted run for my own check. The repo-widepnpm lintis left to CI and is not claimed here.Acceptance notes
bookanddocare given, the build does not check that the doc is a member of the book. Membership is derived at read time from group rules (ADR-0046 §6.2.1), so a build-time check would be contradicted by the next doc a package adds.booktarget resolves to the stack'sbooks, package-bodybooks, or a package id. A book owned by a different package is refused. That matches the ruling's "in the package" anddocs/metadata-embed-ref's same-package posture.allOfin the generated JSON Schema. The generator emits the projectedrequiredOneOftwice onDocNavItem.json, as it already does onAssignmentExpressionValue.json. The two entries are identical, so what the schema accepts is unchanged. Observation only.docitems yet. Until objectui#10188 ships in a pinned console, adocitem parses, is checked and publishes, but the menu does not show it.apps.mdxand the changeset both say so.维护者速读(草稿)
改了什么:应用菜单多了一种「文档」导航项(
type: 'doc'),可以指向一本书(book)、一篇文档(doc),或者两个都填(书里的那一页);两个都不填会在保存时被拒,报错里点名两个键。构建(os build/os validate/os lint)时,如果填的书或文档在本包里不存在,会被拒并给出「你是不是想写 …」的提示。另外在对外技能包skills/objectstack-ui/SKILL.md的导航项表里加了一行。为什么改:按 #19482 的裁决(A,含「可直接链接到 book」的追加)落 spec 这一半;技能包那一行是门禁
check:skill-identifier-liveness要求的——新增的导航项种类必须在技能目录里有一行,否则 AI 作者读不到这个能力。风险与代价(含回滚):纯新增,不改变任何已有写法的判定;控制台要等 objectui#10188 才会画出这个菜单项,在此之前它能存、能校验、但菜单上看不到。技能包那一行的 token 用同文件删字抵账(顺手删掉了对象行里一句与 spec 不符的「目标优先级」说法)。回滚=整 PR revert,无数据迁移。
席位意见:
你要做的:这个 PR 碰了
skills/**,是 Tier H,需要您亲自批准一次;另外请看一眼报告里的开放问题——服务端导航过滤要不要也按文档 audience 把看不了的「文档」项剪掉(目前只有渲染端剪、内容读取有门)。Generated by Claude Code