Skip to content

app.homePageId 退役的前提写错了 —— objectui 的 resolveLandingRoute() 一直在读它,现在成了永远走不到的死分支 #4709

Description

@os-zhuang

发现于 cloud 的 pin bump(objectstack-ai/cloud#1017,462b713a → 16fc124a)对 #4667 逐条对账的时候。不影响 cloud 的行为(下面有核算),但 #4667 的裁决依据本身不成立,且留下了跨仓的死代码,所以单独记一条。

#4667 的前提

packages/spec/src/ui/app.zod.ts 的墓碑文案:

`app.homePageId` was removed in @objectstack/spec 17.0.0 (#4667, ADR-0049) — no shell
ever read it. An app's landing page IS its first navigation item (by `order`) ...

commit 7d215814 的正文同样以「其 describe() 自己的对冲措辞("if not set, usually defaults to the first navigation item")描述的就是唯一存在的行为」为由,把它归进 authorWarn 死键。

但 objectui 读它

objectstack-ai/objectui@a8ad6c0f,packages/app-shell/src/console/AppContent.tsx:

function resolveLandingRoute(activeApp: any, ctx?: NavTemplateContext): string {
  const homePageId: string | undefined = activeApp?.homePageId;
  const navigation = activeApp?.navigation || [];
  if (homePageId) {
    const item = findNavItemById(navigation, homePageId);
    const route = buildItemRoute(item, ctx);
    if (route) return route;
  }
  return findFirstRoute(navigation, ctx);
}

挂在 apps/:appName 的 path="/" 上。它自带的 docblock 把「不是第一项」这件事说得很明确:

Honors the app's explicit `homePageId` (Salesforce-style "Default Landing"); falls back
to the first reachable nav item only when no homePageId is set ... This is what lets the
CRM example open on the Sales Dashboard instead of the Lead list.

findFirstRoute 会跳过 type: 'url' / separator / action,但在导航项之间它就是「第一项」。所以:

  • 「no shell ever read it」是错的 —— console 读,而且是唯一决定 app 内落地页的地方;
  • 「landing page IS its first navigation item」在没有 homePageId 时成立,有的时候不成立,这正是这个键存在的意义。

(RootLandingRedirect 那一层确实只看 isDefault,墓碑文案的后半句没问题;出问题的是前半句和 app 内部这一层。)

现在的实际状态

spec 17 起 homePageId 是 retiredKey():编译期 never、解析期抛错。于是

  1. 任何 app 都无法再声明「落地到非第一项」;findFirstRoute 成为唯一行为。
  2. objectui 里那段 if (homePageId) 连同 findNavItemById 变成永远进不去的死分支——ADR-0078 要清的那一类,只是这次是渲染器侧。
  3. docblock 里承诺的「CRM example 落在 Sales Dashboard 而不是 Lead 列表」这条能力,没有替代写法。

cloud 侧的核算(说明危害面,不是说没事)

cloud 唯一的 homePageId 是 CLOUD_APP.homePageId: 'nav_home',而 nav_home 本来就是 navigation 的第一项、第二项是 type: 'url' 的 Open Production 快捷方式(会被 findFirstRoute 跳过),所以删掉键之后 findFirstRoute 解析到同一个 page/welcome,行为不变。cloud#1017 已按这个核算删掉并写了注释。换一个把落地页指向靠后导航项的 app,同一个删除就是静默的行为变更。

要决定的事(不猜,交给维护者)

两条路,代价不同:

  • A. 承认前提写错,撤回这一条退役:homePageId 恢复为 authorable key,并把 objectui 的 resolveLandingRoute 记进 liveness ledger 的 enforced 一侧(ADR-0049 「有实现就 enforce」)。代价:清空剩余 6 条 authorWarn 死键 —— book ×2 / job.id / translation.validationMessages / app.homePageId / app.areas[].order(ADR-0049,v17 限时) #4667 的 baseline / 墓碑 / conversion 要回退一部分,authorable-surface.json 与 app-dead-authoring-keys-removed 的条目要拆。
  • B. 维持退役,把 objectui 一起清干净:删掉 resolveLandingRoute 的 homePageId 分支与 findNavItemById,并明确记录「落地页只能靠导航顺序表达」这条产品约束(含 CRM 示例那句 docblock 要改)。代价:真的失去一个能力,且 objectui 需要同步一次。

倾向 B,理由是:这个键的语义与「导航顺序」重复表达同一件事,两个来源本身就是 #4411 那类陷阱的温床,而「把想先看到的放前面」是作者可以直接做到的;但 A/B 的分歧是产品能力取舍,不该由适配 pin 的人替维护者决定。无论选哪条,app.zod.ts 里「no shell ever read it」这句都要改——它现在会让下一个读者据此做错判断(本次就是先信了这句、再去核渲染器才发现不对)。

关联

Activity

  1. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    裁决(2026-08-03,维护者已批):B —— 维持退役,附三条强制附带项

    维护者采纳 PM 综合分析(实际业务 / 平台长远 / 防 AI 犯错三轴同向),原文结论要点:

    1. 业务面:「落地页 ≠ 第一项」是真需求但今天生态零使用 —— 本仓 examples 零声明(example-crm 无 homePageId;app-showcase 明确绕开),cloud 唯一一处恰好指向第一项(cloud#1017 已核算清理)。需求是潜在的,不是现存的。
    2. 长远面(决定性):即使将来要此能力,homePageId 也是错误编码 —— ID 交叉引用需要引用完整性,悬空时静默回退(objectui 实现正是如此)。正确形状是导航项自身的标记(如 navigation[].landing: true):单一来源、不可能悬空。撤回退役(A)= 把错误编码重新钉进契约 + 拆 清空剩余 6 条 authorWarn 死键 —— book ×2 / job.id / translation.validationMessages / app.homePageId / app.areas[].order(ADR-0049,v17 限时) #4667 三件套。
    3. 防 AI 面:A 留下两个来源表达同一件事,且悬空 ID 静默回退是 AI 最难自查的错误类;B 只留一种表达,误写者得到墓碑的响亮报错 + 处方。

    强制附带项(B 的组成部分,不是可选)

    • (1) 墓碑与文档文案纠错(本仓,已派发):「no shell ever read it」为假 —— objectui resolveLandingRoute() 曾读它,且 docs/audits/2026-06-appschema-property-liveness.md:18 的 6 月审计本就把它列在 live 清单。ui/app.zod.ts 墓碑文案、生成文档、docs/protocol-upgrade-guide.md 对应行改为诚实版本:「objectui console 曾消费(v17 前),能力随退役移除;落地页 = 第一个可达导航项」。
    • (2) objectui 清理单(跨仓 linkage):删 resolveLandingRoute 的 homePageId 死分支 + findNavItemById,更正 docblock 里 CRM 落地承诺,随 objectui 升 v17 批次执行。
    • (3) 设计占位(记录在此,防 v18 回潮):若将来产品要「落地页 ≠ 第一项」,正确形状是 navigation[].landing 布尔标记(或同构的导航项自身属性),不是恢复 homePageId。届时按 enforce-first 设计:先有渲染器实现与测试,再进 schema。

    流程教训(已纳入后续派发简报)

    #4667 的假前提本可被拦:退役单引用 liveness 结论时必须对账既有审计记录 —— 本次 6 月审计与 8 月退役理由直接矛盾却无人发现,发现者是做 cloud pin bump 对账的第三方会话。


    Generated by Claude Code

  2. self-assigned this
    on Aug 3, 2026
  3. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    认领(本仓执行项 —— 裁决 B 的强制附带项 (1) 文案纠错)

    • PM 会话:session_0176qgxgCXTJCUv4YFLtusP9
    • 分支:claude/issue-4709-homepageid-tombstone-copy
    • 范围:只做文案纠错。不撤回退役、不动 retiredKey() 机制、不动 authorable-surface.json / app-dead-authoring-keys-removed conversion 条目、不碰 content/docs/releases/。
    • 计划改动点:packages/spec/src/ui/app.zod.ts 墓碑文案 → gen:docs 重生成 content/docs/references/ui/app.mdx → docs/protocol-upgrade-guide.md 对应分句 → 相关测试注释 → 新 changeset(说明纠正的是「退役理由的表述」,不是退役本身)。
    • 跨仓项 (2) objectui 清理已另立 objectstack-ai/objectui#3264,不在本 PR 内。

    认领前已读现有评论,除维护者裁决外无其他会话的认领。


    Generated by Claude Code

  4. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    实施完成(本仓执行项) → draft PR #4846

    裁决 B 的强制附带项 (1) 墓碑与文案纠错 已实施。退役本身、retiredKey() 机制、conversion 行为、四张 ratchet、os migrate meta --from 16 处方,一字未动。

    两条核心证据(实测)

    1. objectui 确实读过 —— objectstack-ai/objectui@785b8a5,packages/app-shell/src/console/AppContent.tsx:875 resolveLandingRoute(),:876 读 activeApp?.homePageId,:879 findNavItemById,由 :675 在 apps/:appName 的 path="/" 上调用。找不到时 route 为空,静默落回 findFirstRoute —— 这段实现本身就是裁决 B「ID 交叉引用 + 悬空静默回退」论证的实物。
    2. 6 月审计的矛盾 —— docs/audits/2026-06-appschema-property-liveness.md:18 的 LIVE & necessary 清单里明确含 homePageId。6 月说 live、8 月退役理由说 never read,两份本仓文档矛盾两个月无人发现。

    逐处纠正

    位置 处理
    packages/spec/src/ui/app.zod.ts 墓碑文案 + 上方 docblock 改为诚实版本;处方部分逐字保留
    content/docs/references/ui/app.mdx:86 gen:docs 重生成(未手改)
    packages/spec/src/conversions/registry.ts:1623 只改 summary 散文里 homePageId 那一分句;id/surface/apply/fixture 全未动
    docs/protocol-upgrade-guide.md:200、packages/spec/spec-changes.json 随上条 gen:upgrade-guide / gen:spec-changes 重生成
    .changeset/book-job-…-retired.md:42 判定未消费(仍在 .changeset/ + 不在 pre.json 的 641 条已消费列表 + packages/spec/CHANGELOG.md 零命中)⇒ 就地纠正论证段
    packages/lint/src/lint-liveness-properties.test.ts:268/341 核实后未改 —— 只列举键名,未复述假前提
    docs/audits/2026-06-…-liveness.md 结论原文不动,加后续注记(该键已 v17 退役 + 本审计判定是对的 + 流程教训)
    packages/spec/liveness/app.json homePageId.note 超出清单、核实后判定必改:原文含同一句 No shell ever read it;status/verifiedAt 未动
    content/docs/ui/apps.mdx:288 原文 because nothing ever read it —— 同一句假话,已纠正
    examples/app-showcase/src/ui/apps/index.ts:29 原文 homePageId has no console consumer yet —— 既假又过期,已纠正
    packages/spec/src/ui/app.test.ts 修正复述假前提的注释;新增 pin 测试锁住墓碑不得再出现 no shell ever read,且必须点名 resolveLandingRoute

    裁决的设计占位 (3)(将来用 navigation[].landing,enforce-first)已写进 app.zod.ts docblock 与 liveness ledger note,防 v18 回潮。

    验证

    check:generated 8/8 绿;spec 全量测试 294 files / 7381 tests passed;check:liveness 绿;lint 全量 54 files / 961 tests passed;全仓 typecheck 122/122 successful。合并 origin/main(merge,非 rebase;四张 ratchet 与 main 的 #4809 无重叠,本 PR 一张都没动)后复验 8/8 + app.test.ts 96/96 仍绿。

    不在本 PR 内

    跨仓项 (2) —— objectui 的 if (homePageId) 死分支 + findNavItemById 删除、以及 docblock 里「CRM example 落在 Sales Dashboard」承诺的更正 —— 见 objectstack-ai/objectui#3264,随 objectui 升 v17 批次执行。

    PR 为 draft,未 ready、未 auto-merge。


    Generated by Claude Code

  5. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    内容验收通过 → PR #4846;入队待 #4831 落地后同步(与两单共用 spec-changes.json)

    独立核过 GitHub 实际 diff(ef8c17e3 vs c4ab50b6),裁决 B 的边界守住了:

    「改理由不改退役」这条线没越

    • packages/spec/src/conversions/registry.ts 只改 summary 散文 —— surface / apply / RETIRED 数组一字未动;
    • 五张 ratchet(api-surface / api-surface-signatures / authorable-surface / json-schema.manifest / dual-source-exports.baseline)全部零触碰;
    • content/docs/releases/ 零触碰。

    纠正措辞诚实且不留歧义:never read → unread or wrongly encoded,并明写「homePageId WAS read by objectui's console before v17 but encoded the landing page as an ID cross-reference that silently fell back when it dangled」+「premise corrected in #4709; the retirement stands」。承认假前提的同时,没给「是不是该撤回退役」留下解读空间。

    超出任务书的四处自主发现,同一句假话的其余落点,补得对:packages/spec/liveness/app.json 的 note、content/docs/ui/apps.mdx:288、examples/app-showcase/src/ui/apps/index.ts:29、app.test.ts 注释。其中 liveness ledger 那处理由尤其成立 —— 它正是下一个退役单会去读的记录,假前提留在那里就是复发源。

    判定不改的两处也对:liveness/README.md:511 与 apps.mdx:181 陈述的是 v17 之后的现状,事实成立、不含假前提 —— 没有为了统一措辞去改正确的句子。

    changeset 是否已消费用了三条独立证据(文件仍在 .changeset/、pre.json 641 条不含它、packages/spec/CHANGELOG.md grep 零命中),判定可信,故就地纠正而非另立说明。新增回归 pin 同时锁住解析期报错文案与生成文档文案(retiredKey(guidance) 两处共用同一份 guidance)。

    为什么先不入队

    packages/spec/spec-changes.json 是三单的共同冲突面:#4831(#4657,已在合并队列)与 #4841(#4793,等 #4831)都往里加条目,而该文件在 .gitattributes 里是 merge=os-regen —— merge 时零冲突标记却会静默吞掉一侧改动,只有重新生成才暴露(今日 #4616 实证)。

    已指示实施 agent:等 #4831 落地 → git merge origin/main → spec-changes.json / protocol-upgrade-guide.md 取 main 版后重新生成(本单的改动本就派生自 conversions/registry.ts 的 summary,重生成会正确产出并集)→ 重验并核对「同时含两单条目、#4616/#4657 删除未复活」→ push。之后由 PM 入队(本单是 patch、面小,预计排 #4841 之前)。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions