Skip to content

feat(spec): doc navigation item targeting a book and/or a doc - #19789

Merged
os-zhuang merged 10 commits into
mainfrom
claude/issue-19482-doc-nav-item
Sep 24, 2026
Merged

os-zhuang merged 10 commits into
mainfrom
claude/issue-19482-doc-nav-item

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #19482

Clause-②: yes

The spec half of the ruling on #19482 (letter A, with the book/doc addendum): one doc navigation item variant that opens a book and/or a doc, 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 the NavigationItemSchema discriminated 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, so crm_lead_guide.md is refused).
    • .refine(requiredOneOf(['book', 'doc'])): an item with neither is refused, and the message names both keys.
    • The .describe() states the audience rule from the addendum. A book entry shows the member's pruned subset of pages and is hidden from a member with no readable page. A doc entry the member may not read is hidden. visible / requiredPermissions can only narrow further.
    • Near-miss answers: docName → doc and bookName → book. book / doc written on another variant points at type: 'doc', and the two prose targets are added to PROSE_ALIAS_TARGETS.
  • The "names no existing book/doc in the package" refusal is new rule docs/nav-target in packages/cli/src/utils/collect-docs.ts (lintDocNavTargets, called from collectAndLintDocs, so it runs in os build, os validate and os lint). It refuses with a did-you-mean and a remedy. It walks app navigation, areas[].navigation (children included) and manifest.navigationContributions items.
  • Docs: content/docs/ui/apps.mdx gains a Doc Navigation section, and the app navigation item row in packages/spec/variant-docs.json is re-keyed to include doc (check:variant-docs). skills/objectstack-ui/SKILL.md gains a doc row, which check:skill-identifier-liveness requires (see "Governed surface" below).
  • Regenerated with the repo's generators (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 .changeset/19482-doc-nav-item.md: @objectstack/spec minor, @objectstack/cli minor.

Landing site of the reference check, and why it is not validateCrossReferences

The dispatch assumed the check would go beside the sibling nav-target checks (objectName / dashboardName / pageName / reportName) in validateCrossReferences, packages/spec/src/stack.zod.ts. I measured that site and it cannot see docs. Docs join the artifact from src/docs/*.md in collectAndLintDocs, after the config module (and so defineStack) has already run. For example, examples/app-showcase authors books inline in defineStack but has no inline docs: its nine docs live in src/docs/. A doc arm in validateCrossReferences would 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'])). requiredOneOf is 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.json carries an allOf of anyOf: [{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 an invalid_union aggregate, and it would have split the per-variant strict surface into three.

The allOf entry appears twice. That is a pre-existing generator quirk: automation/AssignmentExpressionValue.json does the same. The two entries are identical, so the result is unchanged. See the Acceptance notes.

The ruling's three confidence gaps — first readings

  1. The book runtime overlay in objectui is not measurable from this repo. There is no objectui checkout in this container (.objectui-sha = 87af769e). Backend side, measured: the book registry row is allowOrgOverride: false (packages/spec/src/kernel/metadata-plugin.zod.ts:1080). That value was rolled back from ADR-0046 §6.4's true (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 answers 403 not_overridable, and new runtime books still work (allowRuntimeCreate: true).
  2. /meta/doc content limits, measured: none at the schema, and none on the save path. DocSchema.content is an unbounded z.string() (packages/spec/src/system/doc.zod.ts). The MDX and image bans run only in the build lint (lintDocs in collect-docs.ts), not on PUT /meta/doc. A grep for a body limit (bodyLimit, maxBody, PAYLOAD_TOO_LARGE) finds none on the metadata routes; the only hit is packages/rest/src/import-prepare.ts.
  3. How the docs audience gate applies to a nav entry, measured: the content is gated server-side, the entry is not. /meta/doc, /meta/book and /meta/book/:name/tree apply 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 on requiredPermissions, requiresService and object servability. It has no audience arm. So the server still sends a doc entry 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.md is a skills/** path, which makes the whole PR Tier H. check:skill-identifier-liveness holds the Navigation Item Types table exhaustive over NavItemVariant. Adding the variant without its row reds that gate, and the other remedy (--update of the gap ledger) is maintainer-only.

Readings:

Measure Before After
skills/objectstack-ui/SKILL.md lines 309 310
All skills/*/SKILL.md lines 4401 4402
skills/objectstack-ui/SKILL.md tokens (ceiling 3856) ≤ 3856 3854

The new row's tokens are paid for by deleting text in the same file:

  • The object row's Target precedence: recordId → filters → viewName was wrong. The spec refuses filters with recordId or viewName and states no precedence (objectNavTargetExclusivity).
  • The page row's (type: 'home' | 'list' | ...) aside is gone; page types are covered in rules/pages.md.
  • The component row's params become 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 unit over collect-docs.nav-target.test.ts, collect-docs.test.ts and collect-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.
  • New spec pins in src/ui/app-doc-nav-item.test.ts:
    • the four accepted shapes, plus a doc entry nested in a group;
    • the missing-target refusal (custom issue at ['navigation', 0], message names both keys) on the union mount and on the standalone export;
    • a filename or path as doc is refused, and so is an empty book;
    • the alias answers;
    • the JSON Schema projection.
  • New CLI pins in 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 real src/docs/*.md doc that resolves through collectAndLintDocs.
  • Existing enumerations updated: 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).
  • Ablation, by the in-repo tool: 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.
    • Anchor count went 1 → 0 and the blob changed, so the mutation reached disk. 3 failed | 10 passed: the two missing-target refusals and the projection pin. The rest stayed green.
    • Restored: blob == HEAD 7ac11bf63025 and git diff HEAD is empty.
    • No dist is involved: the spec suite resolves ./app.zod from source.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 117 families at f9baf5cb3f. All 117 were run on that head and all exited 0. --ran reconciles "117 run, 0 NOT-MEASURED (a DERIVED zero — all 117 recorded an exit code and none of them is 3)".
    • First-pass reds fixed in this PR: check:variant-docs (ledger key + apps.mdx), check:skill-identifier-liveness (SKILL.md row), check-skills-token-ratchet (paid by deletion).
    • Prerequisite refusals (exit 3) rebuilt and re-run to 0: check:skill-examples, check:i18n*, check:dual-build-cjs-loads.
  • npx eslint --no-inline-config over the five touched TypeScript files: exit 0. This is a targeted run for my own check. The repo-wide pnpm lint is left to CI and is not claimed here.

Acceptance notes

  • No doc-in-book membership check. When both book and doc are 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.
  • Book targets are resolved within the package. A book target resolves to the stack's books, package-body books, or a package id. A book owned by a different package is refused. That matches the ruling's "in the package" and docs/metadata-embed-ref's same-package posture.
  • Doubled allOf in the generated JSON Schema. The generator emits the projected requiredOneOf twice on DocNavItem.json, as it already does on AssignmentExpressionValue.json. The two entries are identical, so what the schema accepts is unchanged. Observation only.
  • The console does not render doc items yet. Until objectui#10188 ships in a pinned console, a doc item parses, is checked and publishes, but the menu does not show it. apps.mdx and 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

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:ui tests tooling labels Sep 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/spec, touching 19 documentable anchor(s). ⚠️ 6 changed file(s) yielded no anchor (packages/spec/api-surface/ui.json, packages/spec/authorable-surface/ui.json, packages/spec/declaration-map/ui.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/getting-started/quick-reference.mdx (via NavigationItem (symbol, a top-level type))
  • content/docs/ui/apps.mdx (via NavigationItem (symbol, a top-level type), crm_lead_guide (literal, a string literal in a comment on a changed line; a string literal on a changed line), crm_manual (literal, a string literal in a comment on a changed line; a string literal on a changed line), nav_help (literal, a string literal in a comment on a changed line; a string literal on a changed line), nav_lead_guide (literal, a string literal in a comment on a changed line))
What this run could not see
  • 6 changed file(s) yielded no anchor (packages/spec/api-surface/ui.json, packages/spec/authorable-surface/ui.json, packages/spec/declaration-map/ui.json, …) — pages documenting those are invisible to this run
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 — 142 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 3f9e2eaa1cadee483674449817ee732192172e7b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 795ecedb6b133b25ba1d4604333e547c48466c7b — the merge of head f9baf5cb3f41e8374c433bd79d86ca57e43cb42f into base 3f9e2eaa1cadee483674449817ee732192172e7b, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 3f9e2eaa1cadee483674449817ee732192172e7b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f9baf5cb3f41e8374c433bd79d86ca57e43cb42f

① Derived judgments

  1. DocNavItemSchema (packages/spec/src/ui/app.zod.ts) — strict via navItemSurface('doc'), book? (min(1), a package id is a legal implicit-book target) / doc? (the DocSchema.name rule), refine(requiredOneOf(['book','doc'])) (at-least-one; both-given accepted and pinned), remedy names both keys, describe carries the addendum's audience rule. Matches ruling 5754620525 + addendum 5754645447 exactly; ⛔ no second nav variant (ten variants, doc the only new one).
  2. Discriminated union + both hand-tied types + NavItemVariant widened by one — RIGHT.
  3. New exports DocNavItemSchema / DocNavItem / DocNavItemParsed; generated artefacts (api-surface, export-origins, declaration-map, authorable-surface 13 keys, json-schema manifest, reference mdx, strictness-ledger counts) are generator output, +1 schema / +1 site.
  4. JSON Schema carries the rule: the pin calls projectPublishedJsonSchema(DocNavItemSchema, { io: 'input' }) (the build's own call) and asserts required:["book"] / required:["doc"]; the ablation would fail it. Doubled allOf is a pre-existing generator quirk, accept set unchanged.
  5. Aliases (docname, bookname, page/docs/books on the doc branch, prose targets on the other nine) are refusal-message near-miss answers, ⛔ not accepted keys — accept set not widened past the ruling; pinned in alias-integrity.test.ts.
  6. Landing site of the 「target names no existing book/doc」 refusal — CLI docs/nav-target in collect-docs.ts: justified. validateCrossReferences runs inside defineStack (stack.zod.ts:3370) before src/docs/*.md are collected (collect-docs.ts:1290, reached from compile / validate / lint) — a doc arm there would be dormant or a false refusal (seat re-read both sites). Limitation within the ruling's 「in the package」 scope: runtime PUT /meta/app gets no existence check.
  7. Describe honesty — the audience sentence is the addendum's own, ordered by the ruling; the server nav filter filterAppForUserWithReason has no audience arm (content IS gated at /meta/doc, /meta/book, the tree read); entry pruning is the renderer's (objectui#10188, not yet shipped); apps.mdx and the changeset say the console does not render doc items yet. Honest.

② Semver level

@objectstack/spec minor (Clause-②: yes (widening), new union member + exports, nothing previously admitted refused) — correct. @objectstack/cli minor (a new rule judging only items no stack could carry before) — correct. Changeset prose true line by line.

③ Boundary flags

  • (a) skills/objectstack-ui/SKILL.md (published, Tier H) — forced by check:skill-identifier-liveness; token ceiling real (3854 of 3856). Deletions from the customer-agent seat: 「Target precedence: recordId → filters → viewName」 is FALSE (objectNavTargetExclusivity refuses filters with recordId/viewName) — correct to delete; the page-type list is true but redundant (rules/pages.md); 「params become props」 is true, a minor loss (still in the spec describe and apps.mdx). Neither misleads. The new doc row is accurate.
  • (b) Files outside the claimed spec surface (packages/cli, content/docs/ui/apps.mdx, variant-docs.json) — each justified by the ruling's refusal test or a gate.
  • (c) Server-side audience pruning of doc entries — follow-up, ⛔ not a blocker (today only the entry's label/book/doc names reach a non-reader; content stays gated).
  • ⛔ This record does not lift Tier H: the PR lands only on the maintainer's hand or an authorized approving review.

Implemented-by: claude/issue-19482-doc-nav-item
Reviewed-by: session_01VWsFyWDp8Rjb2Ma6a3Cyo8

VERDICT: PASS

Rendered by an isolated at-tier review subagent (card, rulings, the PR and its check-runs — not the dispatch order), adopted by domain:spec seat 4 after re-reading the landing-site readings on the head. Checks at review time on this head: 33 success, 2 opt-in / path-filter skips, 0 failures.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Maintainer reply recorded — batch #220 governed-draft row · maintainer 「批 #220 同意」 (chat, director seat summon #29, session_01EcrTi7s5oDYPHS4Pi7h31d) 2026-09-24T13:01Z

The row presented was: approve and merge PR #19789 (Tier H: skills/objectstack-ui/SKILL.md), with the seat's recommendation to approve. The in-seat contract review PASSed, CI read 33 green / 2 skipped, and mergeable was clean at that reading. Head f9baf5cb3f is unchanged since the ACCEPT (5789580813 on #19482).

⛔ Approval and merge remain the maintainer's own clicks; no seat approves, readies, enqueues or merges a Tier H PR. needs-user-decision is hung on this PR in this stroke, so it sits on the waiting-for-a-human-merge list, and it comes off with the maintainer's approval or merge. On merge, Fixes #19482 closes the card.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 24, 2026 15:05
@os-zhuang
os-zhuang enabled auto-merge September 24, 2026 15:06
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit ccccdcc Sep 24, 2026
41 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-19482-doc-nav-item branch September 24, 2026 15:44
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants