feat(spec)!: 应用外壳与导航树拒绝未知键(#4001 app 步 PR B) - #4165
Merged
Merged
Conversation
…tree (#4001 app step, PR B) Closes the last high-traffic authorable surface in the unknown-key strictness ratchet. The app shell is the densest hand-authored surface on the platform — a navigation tree is where an author or AI is most likely to write a key from memory — so a silent strip here was the most probable instance of the #3405 trap. - AppSchema and its sub-schemas (branding, navigation area, context selector + its optionsSource/filter blocks, navigation contribution) are .strict(). - NavigationItemSchema becomes a DISCRIMINATED union on `type`. That is what makes strict readable: a plain union of strict members answers one unknown key with an invalid_union aggregate naming all nine branches, while discriminating on `type` first yields a single unrecognized_keys issue against the branch the author actually wrote — at an exact path through nested children — and a mistyped `type` gets its own "Invalid discriminator value". Each variant carries its own suggestion pool, so a `url` item is never told about `dashboardName`. - Still OPEN by design: PageNavItem.params, ComponentNavItem.params and ActionNavItem.actionDef.params — per-target payloads owned by the page / component / action, not by the nav item. Sixth ledger finding, and the first in first-party platform metadata: ACCOUNT_APP declared `defaultOpen` on three navigation groups. That was never a schema key — `expanded` is — so all three shipped COLLAPSED while their author believed they opened by default. Fixed at the producer (contract-first, Prime Directive #12); defaultOpen/open/collapsed/isOpen now alias to `expanded` so the next author is pointed at it. Verified: spec 7103 tests + tsc clean; all 12 check gates; platform-objects 239 (setup/studio/account apps); dogfood 73 files / 425 tests; showcase (58 nav items) / crm / todo validate clean. Refs #4001 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0147tNF4Snk7Ry1KGt4a5PY4
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…tor` The app shell doc claimed eight navigation item types and enumerated eight, omitting `separator` — the schema has declared nine since the inverse-drift fix (#1878/#1891/#1894) that added it to match the objectui renderer's `item.type === 'separator'` branch. The gap matters more now that this PR discriminates the union on `type`: a mistyped `type` reports the valid discriminator list, so the doc's enumeration is the thing authors check it against. And `SeparatorNavItemSchema` is `.strict()` over only `type`/`id`/`order` — it does not spread `BaseNavItemSchema` — so `label`/`icon`/`badge`/`visible`/`requiredPermissions` on a separator are now hard errors where they used to be silently stripped. The "all navigation items share these base properties" table was the exact sentence that would send an author into that error, so it now names the exception. Both claims asserted against the live schema before writing: the union has nine members, and a separator accepts type/id/order while rejecting each of the five base props. Refs #4001 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0147tNF4Snk7Ry1KGt4a5PY4
os-zhuang
marked this pull request as ready for review
July 30, 2026 13:48
os-zhuang
pushed a commit
that referenced
this pull request
Jul 30, 2026
…ge gate did its job Merge origin/main and record the first live graduation: #4165 made the app shell strict while this PR was in CI. The derivation adapted on its own — app fell out of the lintable set because its parse is now loud — and the pinned expectation list is what turned that shrink into a red test instead of a silent one. That is the exact scenario the pin exists for: a human confirms the shrink is a graduation, not a regression, and moves the name from the covered list to the strict list. Changeset and docs updated to stop hardcoding the count and the strict roster — both now describe the rule (the strict bucket grows as tiers graduate) rather than a snapshot of it. 7133 tests green on the merged base; all six gates PASS; the three example apps stay clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXGj3Z5TmwSV6RK2oGc3cb
os-zhuang
added a commit
that referenced
this pull request
Jul 30, 2026
…ection (#3786) (#4178) #4148's lint covered object and field — a sample, not the population: only a handful of authorable types are .strict(); every other type strips an undeclared key with no signal. The walker now covers every lintable collection (16 today), with its coverage DERIVED, not listed: collections from PLURAL_TO_SINGULAR, the judging schema from the type→Zod registry, and lintability read off each schema's own unknown-key posture (strip → lint; strict → skip, the parse is loud; passthrough → skip, nothing is dropped; unions → member-key union). The walker lives in kernel/ beside the schema registry; the comparator and guidance tables stay in data/, frontend-safe. Root export unchanged, so defineStack, os validate and os build pick the coverage up with no change. Mid-review, #4165 graduated `app` to strict — the derivation adapted on its own, and the pinned coverage expectation turned the shrink into a red test until a human confirmed it was a graduation, not a regression. That is the gate working as designed; the strict roster in the test now records it. Verified clean against the three example apps (zero false positives), by mutation in three directions, and 7133 spec tests green on the merged base.
os-zhuang
added a commit
that referenced
this pull request
Jul 30, 2026
…itten doc (#4177) The liveness gate asks whether a property does anything; check-doc-authoring asks whether docs use the right authoring form. Neither asked the inverse-drift question: does a variant the schema DECLARES appear in the prose at all? content/docs/references/ is generated from these schemas and cannot drift; the hand-written pages are typed by humans and do. Founding case, which shipped for months: apps.mdx said the navigation tree "supports eight item types" and enumerated eight. The schema had nine — `separator` was added to match the objectui renderer (#1878/#1891/#1894) and no hand-written page ever learned about it. #4165 raised the cost: a mistyped discriminator now answers with the list of valid values, and the doc's enumeration is what an author checks it against. Identity is `<discriminator>:<variants sorted>`, deliberately not a source path — a union is reachable by several paths and the walk order picks one. Keying on the variant set means adding or removing a variant changes the key, failing the ratchet and sending the author back through the ledger to the doc. variant-docs.json classifies all 20 unions found by reflection (a count that matches `grep -c 'z.discriminatedUnion('` exactly): 5 governed, 15 exempt as either generated-reference-only or not-authorable. Verified it bites, not just that it passes: removing `separator` from apps.mdx fails with exit 1 naming the variant; deleting a ledger entry fails as an undeclared union; mutating a key fails as orphan + undeclared — the pair an author sees when adding a variant. Known limit, stated in the script header: a variant counts as documented via `<disc>: '<variant>'` or `` `<variant>` ``. The second form is loose and can match an unrelated sentence for short generic variants — a pass means the page says the word, not that it documents it correctly. Refs #4001, #4165
os-zhuang
pushed a commit
that referenced
this pull request
Jul 30, 2026
Two conflicts, both in code #4165 ("reject unknown keys across the app shell and navigation tree") rewrote underneath this branch: - `packages/spec/src/ui/app.zod.ts` — main turned the nav union into a `z.discriminatedUnion('type', …)` with `.strict()` members and kept the `z.ZodType<any>` annotation; this branch replaced that annotation with the real `NavigationItem`. Both are kept. Reconciling them needed one addition: main widens the member array (`as unknown as readonly [ZodObject<ZodRawShape>, …]`) because the members are lazySchema Proxies and a superRefine-wrapped variant, and `discriminatedUnion` then reports the union's output as `Record<string, unknown>` — which fits `z.ZodType<any>` and nothing sharper. The union therefore takes a matching cast back to `z.ZodType<NavigationItem>`, documented at the declaration: every branch of the type is still `z.infer<typeof XNavItemSchema>`, so only the MEMBERSHIP of the list is unchecked, and app.test.ts parses all nine variants. - `AGENTS.md` — main added the `check:generated` aggregate section; this branch added `check:exported-any` to the "pure checks with no generator" list. Both are kept. Also registers `check:exported-any` in `check-generated.ts`'s ledger, which main added and which reconciles itself against package.json on every run. Registers `check:variant-docs` in the same ledger while here: #4177 added the script and #4183 added the reconciliation, neither PR could see the other, so `check:generated` was already failing on `main` itself. One line, in a file this branch was editing anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY3swmCsdYx6AMGiRmFt1H
os-zhuang
added a commit
that referenced
this pull request
Jul 31, 2026
…#4232) The ledger is the campaign's map — which z.object sites are authorable (the .strict() ratchet target), which are wire, which are deliberately open. Every step reads it to pick the next move, and nothing kept it honest. It went stale twice in one week: a classification verification disproved (hook.zod.ts carried a blanket `authorable (p)` covering a runtime shape, #4207), and a "next step" that had shipped months earlier (the unknown-key warning layer, #4218), which sent the next reader off to rebuild something already in production. A map that drifts is worse than no map, because it gets followed. CHECKS. Site counts (the ledger states its own method — "z.object( occurrences per file" — so every number is verifiable); coverage (every *.zod.ts with sites in a triaged directory needs a row); section totals; and that a row claiming "strict as of" names a file that really contains .strict(). The Class column is deliberately NOT checked. Authorable vs wire vs open is a judgement about who writes the input, and this campaign's rule is verify-before-tightening — encoding it would automate exactly the call that must stay human. The gate protects the arithmetic and coverage so that judgement is always made against current code. Zero-site files are skipped: five of the six the first run flagged hold no z.object at all (enum/token modules). Not a hole — the day one grows its first z.object( it becomes undeclared and the gate says so. FIRST RUN: 11 DRIFTS, in a file being actively edited by the campaign that owns it. Six counts had moved (ui/app.zod.ts 11 → 18, ui/touch.zod.ts 4 → 7, ui/action.zod.ts 8 → 9, ui/view.zod.ts 51 → 50, ui/responsive.zod.ts 6 → 4, automation/flow.zod.ts 12 → 11), two section totals no longer summed, and automation/io-node-config.zod.ts had no row. The app.zod.ts gap was self-inflicted: the app step (#4165) added seven schemas and updated the row's prose without touching its count. Then it worked for real before merging: main landed automation/builtin-node-config.zod.ts (#4045/#4228, eight sites), and merging main turned the gate red on a branch whose own diff touches no schema. Now classified. Every pre-existing count survived that merge unchanged, so the failure was exactly as narrow as it should have been. Verified the gate bites rather than just passes: a moved count fails (plus its section total), a deleted row resurfaces as an undeclared file, and stripping .strict() from approval.zod.ts fails its "strict as of" claim. Refs #4001
os-zhuang
added a commit
that referenced
this pull request
Jul 31, 2026
…checked (#4244) Follow-up to the #4177 variant/doc gate, using the signal it produced. That gate passes a variant if the page contains either `<disc>: '<variant>'` or a bare `` `variant` ``, and its header says plainly that the second form is loose: a pass means the page says the word, not that it documents the thing. Six variants were passing on the loose form alone — the gate's own "weak pass" list, and a precise worklist. apps.mdx enumerated nine navigation item types but only documented six. `report`, `action` and `component` shared one sentence — "the spec also defines …" — while the other six each had a section with an example. They now have their own, and the intro no longer implies a second tier of half-supported types. The `action` section earns its place: `actionName` lives inside a nested `actionDef` block, not at the top level, which is exactly the shape a reader guesses wrong. Both it and `component` note which sub-object stays OPEN by design (`actionDef.params`, `component.params`) — the sibling contract owns those, and after #4165 everything around them is strict. knowledge.mdx had a table naming `object` / `file` / `http` and no example of any, so the per-kind keys (`contentFields` vs `prefix` vs `urls`) appeared nowhere. Added one example each. Every example was parsed against the real schema before committing — NavigationItemSchema and KnowledgeSourceKindSchema, six for six. Writing docs from memory is how the region example in #4218 ended up using an `id` key that does not exist. Anchored-form coverage for these two unions: 9/12 → 12/12. Carries an empty changeset. The first push asserted a docs-only PR needs none; Check Changeset disagreed and named the fix — an empty changeset is the repo's way for work that releases nothing, and 72 of them already exist. Refs #4001, #4177
59 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
收掉未知键棘轮里最后一个高流量可授权面(前序:#4071 flow+permission、#4099 rls/sharing/position、#4119 approval、#4142 app 死键墓碑)。应用外壳是全平台手写密度最高的面——导航树正是作者(和 AI)最容易凭记忆写键的地方——所以这里的静默剥离是 #3405 陷阱最可能的发生地。
做了什么
AppSchema及其全部子 schema.strict():branding、navigation area、context selector(含optionsSource/filter嵌套块)、navigation contribution。NavigationItemSchema转为按type判别的 union——这正是让 strict 可读的关键。实测对比:invalid_union聚合墙,列出全部 9 个分支的失败unrecognized_keys,只报作者真正写的那个分支children里的未知键navigation.0.children.0type拼错每个变体还带自己的建议池——
url项永远不会被建议dashboardName。仓内先例:view.zod.ts、widget.zod.ts。PageNavItem.params/ComponentNavItem.params/ActionNavItem.actionDef.params——这些是页面/组件/动作各自的载荷契约,不归导航项管。闸门抓到的第 6 条 finding —— 这次在平台自己的元数据里
ACCOUNT_APP在三个导航组上写了defaultOpen。 schema 的键是expanded,defaultOpen从来不是——所以这三个组一直是折叠着发布的,而作者以为它们默认展开。已按 contract-first(Prime Directive #12)修产出方,并把defaultOpen/open/collapsed/isOpen加进别名表指向expanded。值得注意这条的发现位置:不在租户项目里,而在已经发布了若干个版本的第一方平台元数据里。这是对「仓内是干净的」这个推断的第三次证伪。
迁移
被拒的键此前全被剥离、从未生效,删除或改名行为保持。错误自带修复方案,映射包括:
menu/sidebar/tabs/items→navigation、title→label、permissions→requiredPermissions、sort/position→order、defaultOpen→expanded、args→params(actionDef)、primary→primaryColor、url→endpoint(选项源);错层指引:pages/views/flows不是 App 字段,写错变体的载荷会被指向拥有它的type。最要紧的一条别名是
visibleWhen→visible——和 #3746 在动作参数上发现的是同一个坑:ADR-0089 让visibleWhen成为 view/page 的正统拼法,在导航项上借用它过去会把整个可见性门剥掉、让条目无条件渲染。能力门 fail-open 是静默剥离最坏的形态。零破坏实证
tsc干净check:*闸门全过validate全过(showcase 含 58 个导航项)战役状态
至此四大域核心授权面全部覆盖:数据(object/field)、UI(action/view/page/app)、自动化(flow/approval)、安全(permission/rls/sharing/position)。剩余按账本:
data/hook+datasource(小、暂定分类待验证)→ v17 后的 lint 告警层(非破坏,minor 可发)→ v18 用告警期实证数据收长尾。Refs #4001
🤖 Generated with Claude Code
https://claude.ai/code/session_0147tNF4Snk7Ry1KGt4a5PY4
Generated by Claude Code