Skip to content

两个 discovery 生产者都在线上返回 schema 未声明的顶层字段(scoping / features / endpoints),且 REST 形状永远无法通过 DiscoverySchema #4828

Closed

Description

@xuyushun441-sys

发现于 #4817(把 content/docs/protocol/kernel/http-protocol.mdx 的 discovery 一节改成两段式时逐字段核对实现),不在该 docs-only PR 范围内修,未认领。

现象

/discovery 属于「机器可读表面」——SDK、codegen、AI 客户端直接读它(AGENTS.md「Route & surface ownership」第 4 条:machine-readable surfaces must not lie)。但两个生产者返回的顶层键,有三个在 packages/spec/src/api/discovery.zod.ts 里根本没有声明:

线上字段 谁发出 spec 里的声明
scoping({ enabled, resolution, scoped, environmentId }) registerDiscoveryEndpoints,packages/rest/src/rest-server.ts 无。git grep scoping -- packages/spec/src/api/ 只命中 events.zod.ts 的注释
features(顶层 { search, websockets, files, analytics, ai, notifications, i18n }) HttpDispatcher.getDiscoveryInfo(),packages/runtime/src/http-dispatcher.ts 无 —— 而且 DiscoverySchema 的设计说明里明写「capabilities/features was removed because it was fully derivable from services[x].enabled」(discovery.zod.ts:211)
endpoints(routes 的重复副本,注释写 "Alias for backward compatibility with some clients") 同上 无

同时:

  • REST 形状永远无法通过 DiscoverySchema.parse()。该 schema 把 name / environment / locale 声明为必填,而 getDiscovery()(packages/metadata-protocol/src/protocol.ts)三个都不产出,rest-server 也不补。
  • dispatcher 形状不产出 capabilities(schema 里是可选,所以不报错),于是同一个协议概念被两个生产者用两套互不相交的字段表达:REST 说 capabilities,dispatcher 说 features。
  • environment 在 schema 里是 z.enum(['production','sandbox','development']),dispatcher 直接塞 getEnv('NODE_ENV', 'development') 的原始值,NODE_ENV=test / staging 都会落在枚举外。

之所以没人发现:唯一在协议层实际引用的是 GetDiscoveryResponseSchema(packages/spec/src/api/protocol.zod.ts:122),它是 DiscoverySchema.partial().required({version:true}).extend({apiName}),而 zod object 默认 strip 未知键 —— 于是「未声明的字段」和「缺失的必填字段」两类问题都被这层宽松包装吃掉了,没有任何 gate 在两端比对。这正是 Prime Directive #10 的 declared ≠ enforced 形状,只是方向反过来:enforced 的比 declared 的多。

为什么值得单开一条

这不是文档问题(#4817 已按实现实际返回的形状把两份示例写对了,包括 scoping / features / endpoints),而是契约问题:文档现在忠实描述了一个 spec 没有声明的线上形状。要么把这三个键补进 schema(并说清 features 与 capabilities 谁是正,endpoints 的退役时间表),要么从生产者删掉。

建议的决策点(需要维护者定,不要猜)

  1. features vs capabilities:统一到一个,还是承认两个端点各有一套?若统一,哪个是正、另一个按 ADR-0087 走退役?
  2. endpoints 这个 backward-compat 别名还有真实消费者吗?没有就删;有就声明并给退役时间表。
  3. scoping 是 REST 层的真实能力协商信息,应当补进 schema而非删除 —— 但补在 DiscoverySchema 还是一个 REST 专属的扩展 schema 里,取决于第 1 点怎么定。
  4. DiscoverySchema 的必填 name / environment / locale:REST 端点补上它们,还是承认 DiscoverySchema 描述的只是 dispatcher 形状、REST 形状另有其名?

修改落点在 packages/spec / packages/rest / packages/runtime,与 #4817 的 docs 车道不同,故未并入。

发现于 #4817,未认领 —— 谁开工谁按 AGENTS.md 认领。

Activity

  1. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    domain:specDomain lane — seat card indexed by label:pm:seat
    on Aug 3, 2026
  2. baozhoutao commented on Aug 5, 2026

    @baozhoutao
    Contributor

    补一条同族证据(来自 #5544 的实施,界外发现,查重命中本单故不另开单):决策点 4 的 name 缺失,在 metadata-protocol 侧不只是「不产出」,而是产出了 spec 标注为 deprecated 的别名 apiName。

    • packages/metadata-protocol/src/protocol.ts:2509(getDiscovery() 的返回体):apiName: 'ObjectStack API',没有 name。
    • packages/runtime/src/http-dispatcher.ts 的 getDiscoveryInfo() 走的是另一套:同仓 packages/runtime/src/http-dispatcher.root.test.ts:38 的注释原话就是 “getDiscoveryInfo returns 'name' not 'apiName'”。
    • spec 侧 packages/spec/src/api/protocol.zod.ts:118-127 把 apiName 明写为 “deprecated — use name”,只在 GetDiscoveryResponseSchema 上 .extend(),而 canonical 的 name 在 DiscoverySchema(discovery.zod.ts:216)里是必填。

    于是同一个「API 名字」概念,两个生产者分别只发 apiName 和只发 name,消费者两边都拿不到稳定的键——与本单 features vs capabilities 是同一种分裂,只是发生在一个 spec 已经标注退役的别名上。决策点 4 定案时建议把 apiName 的退役时间表一并定了(ADR-0087 路径),否则「用 name」的结论落到 metadata-protocol 这条产出路径上是空的。

    对 #5544 的影响(记录在案):正因为两个生产者拼法相反、且集成套件跑在一台外部服务器上无法在本单证伪,#5544 的 PR 没有把 01-discovery.test.ts 的 apiName 断言改写成 name —— 只按 #5449 的约定修了 possibly undefined(可选链断言,不加 ! 也不加 ?? '' 兜底)。改哪个键是本单的契约裁定,不是测试文件能替它决定的。


    Generated by Claude Code

  3. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    Contributor

    维护者裁定(部分)(2026-08-05,经 spec 车道 PM session_018fxLGQdatPbBUvCgiVxg6D 誊写)—— 四个子决策中三个已定,一个仍待裁:

    1. 单一正名 + curated alias,不双正名并存 —— 已裁定。PM 执行默认(带否决窗口):正名取 capabilities(它是 DiscoverySchema 既有声明键;features 是未声明却在发的那个),features 按 ADR-0087 走退役/别名路径。若维护者要反向(features 为正),回一句即改 —— 这条默认只是沿用既有 schema 声明,不引入新契约形状。
    2. endpoints 别名 —— 已裁定:按 ADR-0049 enforce-or-remove 常规退役流程处理(先测真实消费者,无消费者即删,有则声明+退役时间表)。
    3. scoping —— 已裁定:补进 schema 显式声明(REST 层真实能力协商信息,事实已存在只是没写进协议);随第 1 条取单一 DiscoverySchema 形状,以 optional 键落位。
    4. 必填 name / environment / locale vs REST 实际形状 —— 仍待维护者定权威侧:是 REST 端点补齐三键(schema 为权威),还是承认 DiscoverySchema 只描述 dispatcher 形状、REST 另立(实现为权威)。这是唯一真正的形状分歧,environment 枚举外取值(NODE_ENV=test/staging)也随这条一并定。

    needs-user-decision 标签保留,仅剩第 4 条。第 4 条裁定后本单整体转队(1–3 可与 4 同一 PR 落地,避免 discovery 面两次动刀)。


    Generated by Claude Code

  4. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    Contributor

    维护者裁定(第 4 条,收口)(2026-08-05,经 spec 车道 PM session_018fxLGQdatPbBUvCgiVxg6D 誊写):schema 为权威,REST 补齐三键。

    • DiscoverySchema 的必填 name / environment / locale 维持必填;packages/rest 的 discovery 端点(及其上游 getDiscovery())补齐产出这三个键,而不是给 schema 降级或另立 REST 专属形状。
    • 随之而来的强制推论:所有生产者的 environment 取值必须落在声明枚举内(production | sandbox | development)。dispatcher 直塞 getEnv('NODE_ENV') 的做法要改为映射到枚举(test/staging 等枚举外取值如何归置属实现层选择,dev 在 PR 里给出映射表并文档化;语义拿不准就回报,不要猜)。
    • 至此四条子决策全部已裁,汇总:① 正名 capabilities,features 按 ADR-0087 退役/别名(默认带否决窗口,未被否决);② endpoints 走 ADR-0049 退役流程(先测真实消费者);③ scoping 以 optional 键补进 DiscoverySchema;④ schema 为权威,REST 补齐三键。
    • 落地形态:四条一个 PR(避免 discovery 机器可读面两次动刀),验收核心是两个生产者的线上形状都能通过 DiscoverySchema.parse() 严格校验——把「宽松包装吃掉两类问题」的 GetDiscoveryResponseSchema 侧也接上比对闸门,防回潮。

    摘除 needs-user-decision,转 pm:queue。当前批次 5/5 满员,排在 #4611 之后的下一个空槽。


    Generated by Claude Code

  5. added
    pm:queueReady for the PM dispatch loop
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    pm:queueReady for the PM dispatch loop
    on Aug 5, 2026
  6. self-assigned this
    on Aug 5, 2026
  7. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    Contributor

    PM 认领(spec 车道 PM,session_018fxLGQdatPbBUvCgiVxg6D,2026-08-05)


    Generated by Claude Code

  8. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    Contributor

    开发完成汇报(os-dev,分支 claude/issue-4828-discovery-surface,draft PR #5682)

    四条裁定已在一个 PR 内全部落地。几点需要维护者过目的判断:

    1. endpoints 判定:删除(裁定②的分支已定)

    三仓消费者普查(objectstack / objectui / cloud),零真实消费者:

    候选命中 复核结论
    packages/client/src/index.ts 走 discoveryInfo.routes,不读 endpoints
    objectui useApiDiscovery.ts 的 catalog.endpoints 它自己硬编码的 SERVICE_ENDPOINT_CATALOG,与本载荷无关
    objectui useDiscovery.ts 只读 services
    cloud 只把 /discovery 当路径前缀用(SKIP_PATH_PREFIXES),从不读载荷

    阳性对照:同一套 grep 能命中 capabilities 的真实消费者(packages/client 的 getter)和 scoping 的真实消费者(client.environment-scoping.test.ts),所以「零命中」不是 grep 失灵。

    2. environment 映射表(裁定④要求的那张表)

    NODE_ENV 广播值 理由
    production、prod production 正式/简写
    sandbox sandbox 精确
    staging sandbox 预生产、类生产
    development、dev development 正式/简写
    test development 临时的开发者级运行
    未设置 / 其它 development 保持原默认,且绝不在猜测时宣称 production

    prod/dev 简写被接受,与 seed-loader.ts 既有的 NODE_ENV_TO_SEED_ENV 同理由:NODE_ENV 是操作员提供的第三方边界变量(PD #9 明列),归一化它不是 PD #12 禁止的消费者侧容忍。

    staging → sandbox 是表里唯一的判断题,单独标出请裁:它一定不是 production;在 sandbox 与 development 之间,staging 是类生产的,故取 sandbox。要改判只需改这一行。

    3. 一处与派发预设不同的结论:ADR-0087 D2 转换表不适用

    派发单提到「features 按 ADR-0087 走 curated alias/tombstone」。实施时核对了 D2 的适用面:转换层作用于加载期的被授权元数据(normalizeStackInput 那个缝,flow.node.type / page.kind 这类键)。响应载荷没有加载缝 —— discovery 每次请求现算,不存在需要在加载时改写的存量形状,登记 D2 条目不会有任何代码去应用它。

    而且 features / endpoints 从未被声明过,所以也没有「从 schema 删除 + 立 tombstone」这一步(retiredKey() 的前提是键在 schema 的 shape 里)。

    故走的是本仓既有的 API 面退役先例(#4939 registry-retirement.test.ts):停发 + pin 测试钉死不许回潮;apiName 因为确实被声明过,给了明确时间表(协议 18)写在声明处。packages/spec/liveness/ 同样不适用(它以注册表为根、走可授权元数据类型,discovery 不在其中)。

    如果维护者认为仍应在某处登记一条机器可读的退役记录,请指个位置,我另开 PR 补。

    4. 验收核心的一个补充(重要)

    裁定写的验收核心是「两个生产者的线上形状都能通过 DiscoverySchema.parse() 严格校验」。实施时发现这条对必填键那一半充分,对退役那一半不充分:zod object 默认 strip 未知键,所以 parse() 根本看不见 features/endpoints 这类多余键 —— 光有它,退役的那一半是没有闸门的。

    所以每个生产者的闸门是两条断言:

    • DiscoverySchema.parse() 判值(必填键、environment 落枚举、scoping 形状);
    • 发出的键集 ⊆ GetDiscoveryResponseSchema 的 shape,判键(不许发协议没声明的东西)。允许集直接从 schema 推导,而不是手写数组,免得闸门自己变成第三种方言。

    先证红也印证了这个分工:getDiscovery() 未改时第一条红(name: invalid_type / environment: invalid_value / locale: invalid_type),第二条绿 —— 它的病是少键不是多键;多键那一半在 dispatcher 上。

    范围外发现(均已另开单,未认领)

    另:本 PR 是 #3877 Stage A 点名的第一个族(discovery)的落地。

    conversions/registry.ts 未改动,与 #4923 无重叠。


    Generated by Claude Code

  9. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    Contributor

    验收通过(spec 车道 PM,session_018fxLGQdatPbBUvCgiVxg6D,2026-08-06)—— PR #5682,四裁定一次落地。

    转 ready + auto-merge,队列落地。


    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

Labels

domain:specDomain lane — seat card indexed by label:pm:seatpm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions