`app.areas[].visible` and `app.areas[].requiredPermissions` leave the authorable
contract. They were not ordinary dead keys: they were capability gates that
FAILED OPEN. `filterAppForUser` (packages/rest/src/rest-server.ts) checks the
app's `requiredPermissions`, then walks ONLY `item.navigation` — it returns
early when that tree is absent and never reads `item.areas` at all — while the
client renders every area in the switcher. An author writing
`requiredPermissions: ['sales.admin']` on an area got a clean parse, a stored
value, and an area visible to everyone.
What made them read alive is that the SAME names are enforced one level up and
one level down: app-level `requiredPermissions` drops the whole app server-side,
and a navigation ITEM's `requiredPermissions` / `requiresService` are stripped
server-side and re-checked in the shell, whose item-level `visible` is a real
CEL gate. Three layers, of which the middle one was theatre — ADR-0078 false
compliance, the `capabilities.readOnly` shape (#4583).
Route B (remove) per the maintainer's ruling. Route A (enforce) is not wrong,
it is unscoped: area filtering needs semantics decided first (does filtering an
area remove its items everywhere? does the server bind `user` for area CEL?),
which the 17.0.0 window cannot hold — and a retirement PR must not invent an
authorization mechanism. Shipping the major with the gate still declared would
have kept authors writing it for all of 17.x.
`NavigationAreaSchema` is `.strict()`, so this is the strict-deletion route:
the keys leave the shape and two `guidance` prescriptions carry the upgrade.
The prescriptions name the layers that DO enforce rather than saying "removed",
and carry the caveat that per-item gating INSIDE an area is shell-side only —
trading one false belief for a weaker one would repeat the defect. The three
alias spellings that used to RENAME onto these keys (`visibleWhen`, `visibleOn`,
`permissions`) become prescriptions too: an alias pointing at a key that is
itself gone answers an unknown key with a second unknown key (#4667 `sort`
precedent).
ADR-0087: a dedicated `app-area-fail-open-gates-removed` conversion rather than
two more keys on `app-dead-authoring-keys-removed`. That entry's summary is a
list of inert authoring keys; this is a security finding, and the summary string
is what spec-changes.json, the upgrade guide and the `spec_changes` MCP tool
serve — folded in, "a gate that never gated has been removed" would arrive
buried in a sentence about `version` and `mobileNavigation`. Wired into the
protocol-17 D3 chain step with its rationale.
Ledgers, per route: the liveness rows are DELETED (strict removal — the keys
left the walked shape, and retained rows report ORPHAN), and the CLI advisory
lint falls silent by itself, its test flipped from warn-pin to silence-pin with
an anti-vacuity guard. The strictness ledger needs no change and was not
touched: `check:strictness-ledger` counts `z.object(` SITES per file, and this
removes keys from an existing site — the C3 lesson applies to deleting a
`.strict()` shape, which this is not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176qgxgCXTJCUv4YFLtusP9
Fixes #4651
按维护者裁决走 路线 B(移除):把
app.areas[].visible与app.areas[].requiredPermissions从作者契约里真正退役。这两个键不是普通死键,是fail-open 的访问闸门——作者写下它们会得到干净的 parse、被存下来的值,以及一个对所有人可见的 area。一、两键零消费者:证据链
服务端权威闸门
filterAppForUser(packages/rest/src/rest-server.ts)::1814requiredPermissions—— 不满足直接return null,整个 app 从/meta消失:1823const nav = Array.isArray(item.navigation) ? item.navigation : null; if (!nav) return item;:1830filterNav递归检 nav 项级requiredPermissions/requiresServiceitem.areas从头到尾一个字都没读——:1823在navigation缺席时直接早返回。客户端(objectuipackages/layout/src/NavigationRenderer.tsx:894)同样只对 nav item 做checkPerm,area 切换器渲染每一个 area。它为什么读起来像活的:同名的兄弟键上下两层都是真的。app 级
requiredPermissions服务端强制,nav 项级requiredPermissions/requiresService服务端剥离 + 客户端复检,nav 项级visible是真的 CEL 闸门。三层里中间那层是演的——ADR-0078 false compliance,与 #4583capabilities.readOnly同一个模子。全仓复核过其余可能的消费者,没有一个读这两个键:
packages/lint/src/validate-capability-references.ts会走进rec.areas,但那是为了够到 area 里面的 nav 项(它只是泛型地 flag 每个节点的requiredPermissions,area 节点从此不再带这个键就不会命中);validate-nav-access/validate-nav-target-refs/validate-object-references/validate-action-name-refs/validate-translation-references/cli doctor/stack.zod.ts一律只读area.navigation。该文件的注释已按实情更正(仍保留遍历,删掉遍历会让 area 内的每一项失去能力校验)。未采纳 A(enforce),理由不是它错,是它先要定语义:area 被过滤后其下 nav 项整体消失还是仍按自身权限参与其它 area?服务端 CEL 求值上下文有没有
user绑定?那本身是一轮决策,v17 窗口塞不下;而 B 是破坏性的,错过changeset pre exit就要让这个 fail-open 闸门带着整个 17.x 跑。一个从不 gate 的闸门,删掉严格安全于留着。二、迁移处方(strict 删除路线)
NavigationAreaSchema是.strict(),所以走「删键 +guidance处方」,不是 tombstone。处方不说「已移除」了事,而是点名真正生效的两层:areas[].requiredPermissionsnavigation项的requiredPermissions/requiresService,或上浮到 AppSchema 的requiredPermissionsareas[].visiblevisible是逐项求值的处方里带了一条诚实的限定,而不是藏起来:area 里面的 per-item 闸门只由客户端强制(服务端不走
areas),所以「绝不能到达浏览器」的东西要放进 app 的顶层navigation树、或者独立成一个 app。把一个错误的安全感换成一个更弱的安全感,就是重犯本单要终结的那个错误。三个别名拼写
visibleWhen/visibleOn/permissions原本 rename 到这两个键上。键都没了,别名就成了「用一个未知键回答另一个未知键」,因此全部改成guidance处方(#4667 的sort先例)。三、ADR-0087 登记
新增独立 conversion
app-area-fail-open-gates-removed(toMajor: 17,retiredFromLoadPath: true,surfaceapp.areas.visible / app.areas.requiredPermissions),并接进 protocol-17 的 D3 chain step(conversionIds+rationale各加一段)。为什么不并进隔壁的
app-dead-authoring-keys-removed:那条的 summary 是一串「无人读的作者键」,而这两个是安全发现;summary 字符串正是spec-changes.json、生成的升级指南和spec_changesMCP 工具端给升级者看的东西。并进去,「一个从不 gate 的闸门被移除了」会被埋在讲version和mobileNavigation的句子里——而它恰恰是这个 release 里带 gated area 的作者唯一必须读到的一行。fixture 刻意做了两个 area:一个声明了两个假闸门(被剥离,2 条 notice),一个把同名的真闸门写在里面的 nav 项上(必须原样保留)。按键名盲扫会把能用的闸门跟假的一起删掉——见下面 sabotage ⑥b。
⛔ 没有把这两个键写进
RENAMED_DEFS:那张表的不变式是「没有 key 离开契约」,本单是真退役,authorable-surface.json净减 2 个 key。四、两本账:实跑结论,不是假设
活性账本(
packages/spec/liveness/app.json)——必须改,两行删掉。 实证(先把两行放回去再跑):_note与areas的 note 同步收口,nav 项visible的 note 从「对照 areas[].visible,它不是」改成指向本次处方的落点。liveness/README.md的 app 行按check-liveness.mts --json重新生成:dead 11 → 9(原文 Notes 里写的 "Dead 12" 本身也是旧的,一并改对)。严格性台账(
docs/audits/2026-07-unknown-key-strictness-ledger.md)——不用改,且已实跑确认。check:strictness-ledger数的是每个文件的z.object(站点数(脚本自己的说明:site counts arez.object(occurrences per file),本单是在一个既有站点里删键,站点数零变化;ui/app.zod.ts的 18 与「strict as of #4001 PR B」的断言都仍然成立(schema 依然.strict())。C3 的教训针对的是删掉.strict()形状,本单不是那种。gate 实跑PASS。五、生成物
authorable-surface.json没有手编:直接rm掉整个文件再跑gen:schema重新生成,diff 正好是那两行(8262 → 8260 keys)。顺带留个记录给 #4650:strict 删除路线下,gate (a) 会先红:
它无法区分「合法的 strict 退役」和「删基线行洗掉证据」——正是 #4650 要堵的洞。本单的删除是合法的那种(有 conversion、有 guidance、有 changeset),全部证据在这个 PR 里。
check:generated --fix只重生了它证明过期的三个:spec-changes.json、docs/protocol-upgrade-guide.md、content/docs/references/**。⛔ 没碰content/docs/releases/。六、每条 pin 都实际破坏过一次
新增 pin 全部做了 sabotage 实证(下面都是真实输出摘录,不是「加完是绿的」)。
① 把两个键加回 schema → parse pin + 类型 pin 一起红:
② 走错路线:改成
retiredKey()tombstone 而不是 strict 删除(parse 仍然拒绝,但键回到 TYPE 里)→ 类型 pin 照样红:这条是本单类型 pin 存在的理由。#4642 已证本包编译期 pin 空转(
tsconfig.json排除**/*.test.ts、vitest 不开 typecheck),所以类型断言写成Assert< Equal< … > >根本不会跑。这里用 TypeScript compiler API 解析符号身份、读NavigationArea的成员列表,并带两道防空转守卫:模块符号必须解析成功,且成员列表必须全等['description','icon','id','label','navigation']——后者防的是z.infer退化成any时成员数为 0、两条not.toContain空过。③ 阉掉 app 级闸门(
rest-server.ts:1814)→ 真闸门 pin 红:④ 阉掉 nav 项级闸门(
rest-server.ts:1830)→ 同一组 pin 红:③④ 是本单的第二道要求:删掉假闸门之后,必须有人盯着真闸门还在不在,否则这次移除只是把「有闸门的错觉」换成「没人检查闸门」。
⑤ 把 conversion 从 D3 chain step 里摘掉 → chain-replay 红(这就是「没接线」的表现形式):
⑥a
expectedNotices2 → 4 → fixture 红:⑥b 让 conversion 按键名盲扫、连 area 内 nav 项上的真闸门一起剥 → fixture + chain 一起红:
⑦ 把两条账本行放回去(带
authorWarn) → CLI 顾问 lint 的静默 pin 红:CLI 那条从「warn pin」翻成「silence pin」,并加了一条防空转守卫:
lintLivenessProperties找不到账本目录时返回[],「没有 finding」和「lint 坏了」长得一模一样,所以同一次调用里同时写入object.externalSharingModel(全仓最后一条authorWarn),要求它仍然报警。七、changeset 定级理由
@objectstack/specmajor,而且据实写了迁移,没有照抄 C9(#4695)/C12(#4710) 的「零元数据迁移」——那个结论只对改名成立。本单是真退役:这两个是作者可写的元数据键,schema 是.strict(),既有app元数据带着它们会解析失败,authorable-surface.json净减 2 个 key。changeset 正文给出 FROM → TO 表、一行修法、os migrate meta --from 16,以及那条「area 内 per-item 闸门只有客户端强制」的限定。八、门禁
@objectstack/spec buildcheck:generated(8 项)check:spec-changes/check:upgrade-guide/check:authorable-surface/check:api-surface/check:docscheck:liveness/check:empty-state/check:strictness-ledgercheck:dual-source-exportscheck:i18n/check:doc-authoring/check:release-notes/check:adr-anchorstesttestturbo run typecheckTasks: 122 successful, 122 totalturbo run testTasks: 133 successful, 133 total九、顺带
NavigationAreaSchema的 JSDoc@example里还留着order: 1——清空剩余 6 条 authorWarn 死键 —— book ×2 / job.id / translation.validationMessages / app.homePageId / app.areas[].order(ADR-0049,v17 限时) #4667 删了键但漏了这个示例,等于生成的参考文档在演示一个 schema 会拒绝的键。因为就在本次重写的同一个注释块里,顺手改正并把示例换成「把闸门写在 item 上」的正确形态;这是本 PR 唯一一处越出两个键之外的改动,据实说明。.changeset/govern-remaining-nine-metadata-types.md里「app.areas[].visible/areas[].requiredPermissionsare fail-open dead gates」是那次审计的历史记录,不是给作者的处方,故未改动(改别人的 changeset 只会制造冲突)。🤖 Generated with Claude Code
https://claude.ai/code/session_0176qgxgCXTJCUv4YFLtusP9
Generated by Claude Code