feat(spec,runtime,metadata-protocol)!: discovery 两个生产者统一到一个 schema —— capabilities 正名、features/endpoints 退役、scoping 声明 (#4828) - #5682
Conversation
…producers (#4828) `/discovery` is a machine-readable surface, but nothing ever compared what the two producers emit against what `packages/spec` declares. The only schema the protocol layer referenced was `GetDiscoveryResponseSchema` — `DiscoverySchema.partial().required({version}).extend({apiName})` — so `.partial()` hid every missing REQUIRED key while zod's default unknown-key strip hid every UNDECLARED emitted one. The two producers then drifted in opposite directions through the same blind spot. Per the maintainer's 2026-08-05 ruling (all four sub-decisions), landed as one PR so the surface is cut once: 1. `capabilities` is canonical. The dispatcher's undeclared top-level `features` map becomes `capabilities` in the declared `{ enabled }` shape. This fixes a real defect: the SDK's `client.capabilities` getter reads `discoveryInfo.capabilities`, so against a dispatcher-served host it returned `undefined` for every flag while the answers sat one key away. 2. `endpoints` removed (ADR-0049 enforce-or-remove). It duplicated `routes` verbatim; a consumer census across objectstack/objectui/cloud found no reader. 3. `scoping` declared as an optional key, matching what REST actually emits — measured, not guessed. It has a live consumer in `packages/client`. 4. `DiscoverySchema` is authoritative: `getDiscovery()` now fills the required `name`/`environment`/`locale`, and `environment` is mapped into its declared enum instead of passing `NODE_ENV` through raw (`test` and `staging` were landing outside it). `name` is canonical on both producers; `apiName` stays as a deprecated alias with the identical value, scheduled for removal in protocol 18. The gate: each producer package carries a `discovery-schema-conformance.test.ts` that parses its LIVE shape against `DiscoverySchema` (a VALUE verdict) and checks its emitted key set against `GetDiscoveryResponseSchema`'s shape (a KEY verdict). Both are needed — zod strips unknown keys, so the parse alone cannot see the `features`/`endpoints` class of drift at all. `discovery.test.ts` pins the two schemas' key sets equivalent modulo declared aliases so neither can grow a key alone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 3 package(s): 115 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
反向验证实跑结果 —— 三条预测全部命中把 对照运行前写下的预测:
第 3 条是这次反向验证真正的收获,值得单独说:值层的
这就是每个生产者闸门写成两条断言(判值 + 判键)而不是一条的原因,也是为什么第二条的允许集是从 验证完毕后工作树已还原, 补充:其余验证
client 那 3 个文件第一次跑是红的,原因是 Generated by Claude Code |
Fixes #4828
按 2026-08-05 维护者裁定(四条子决策全裁,见 issue 内 19:47Z 收口评论),四条一个 PR 落地,避免 discovery 机器可读面两次动刀。
病根:一个宽松包装同时吞掉了两类错误
/discovery是机器可读面(AGENTS.md「Route & surface ownership」第 4 条),但此前没有任何闸门把生产者实际发出的形状和packages/spec声明的形状放进同一个断言。协议层唯一引用的是GetDiscoveryResponseSchema——DiscoverySchema.partial().required({version}).extend({apiName}):.partial()让缺失的必填键照样解析通过;于是两个生产者朝相反方向漂移,却都从同一个盲区穿过去了:一个不发
name/environment/locale,另一个凭空发features/endpoints。现在两个 schema 的职责被拆开,并且互相对账:
DiscoverySchema对生产者有约束力 —— 三个生产者包各带一个discovery-schema-conformance.test.ts,拿线上真实形状做DiscoverySchema.parse();GetDiscoveryResponseSchema是消费者侧的宽松解析,它的键集就是生产者闸门的允许集(=DiscoverySchema的键 + 已声明的废弃别名);packages/spec/src/api/discovery.test.ts钉住两者的等价关系,任何一边单独长出一个键都会红。这两条断言刻意回答不同的问题:
parse()判值(必填键在不在、environment落没落在枚举里),键集判键(有没有发协议从没声明过的东西)。四条裁定的落地
①
capabilities正名,顶层features退役dispatcher 的顶层
features改为DiscoverySchema一直声明着的capabilities,取声明的{ enabled }层级形状,标志位一个不少。这修掉了一个真实缺陷:本仓 SDK 的
client.capabilitiesgetter 读的是discoveryInfo.capabilities,所以面对 dispatcher 供的 discovery,它对每一个标志位都返回undefined—— 答案其实就在一个键之外的features里。注意保留的那个
features是能力条目内部的子键(capabilities.某域.features),它是声明过的,不动。②
endpoints—— ADR-0049 走「先测消费者」,判定为删除endpoints是routes的逐字副本,注释写着 "Alias for backward compatibility with some clients"。三仓普查(2026-08-05,objectstack/objectui/cloud)零消费者:packages/client/src/index.tsdiscoveryInfo.routes解析路由,不读endpointsobjectuiuseApiDiscovery.ts的catalog.endpointsSERVICE_ENDPOINT_CATALOG,与本载荷无关objectuiuseDiscovery.tsservicescloud/discovery当路径前缀用,从不读载荷阳性对照:同一套 grep 能正常命中
capabilities的真实消费者(packages/client的 getter)与scoping的真实消费者,所以「零命中」不是 grep 失灵。故按 enforce-or-remove 直接从生产者删除,
routes是声明过的正名。③
scoping—— 按实测形状补进 schema先测再声明,不猜:
enabled: boolean/resolution三值枚举(与RestApiConfig.projectResolution同源)/scoped: boolean/environmentId?: string。normalizeConfig保证前两个恒有值,environmentId仅在环境作用域挂载上出现,故 optional。它一直是真实的能力协商信息且一直有消费者(
packages/client的client.environment-scoping.test.ts直接断言scoping.enabled/scoping.resolution),所以是补声明而不是删。dispatcher 不挂作用域变体,不发这个键,因此整体 optional。④ schema 为权威 —— REST 补齐三键 +
environment强制落枚举getDiscovery()现在产出name/environment/locale(locale与 dispatcher 用同一套 i18n 访问器推导,两边不会给出不同答案)。environment不再直塞NODE_ENV,改为映射。映射表(实现在@objectstack/spec/api的resolveDiscoveryEnvironment,两个生产者共用):NODE_ENVenvironmentproduction、prodproductionsandboxsandboxstagingsandboxproduction,而sandbox是枚举里的预生产成员development、devdevelopmenttestdevelopmentdevelopmentgetEnv('NODE_ENV', 'development')默认;绝不在猜测时宣称 production最后一行是安全相关的:识别不了的拼法降级到
development,这个函数不可能替一个没认出来的环境广播production。prod/dev简写被接受,理由与seed-loader.ts里既有的NODE_ENV_TO_SEED_ENV完全一致 ——NODE_ENV是操作员提供的、第三方边界上的变量(Prime Directive #9 明列),对它做归一化不是 PD #12 禁止的「消费者侧容忍」,那条规矩管的是我们自己的元数据契约。staging → sandbox是本表唯一的判断题,单独标出请裁:它一定不是production;在sandbox与development之间,staging 是类生产的,故取sandbox。若要改判,只需改这一行。附:
apiName的退役时间表getDiscovery()此前只发 spec 标注为 deprecated 的apiName而不发 canonical 的name,dispatcher 只发name—— 同一个概念两个生产者拼法相反,消费者两边都拿不到稳定的键。现在:name为正;apiName,并从GetDiscoveryResponseSchema移除。需要在 18 之前迁移的消费者(三仓实测):只有
packages/client/tests/integration/01-discovery.test.ts(TC-DISC-001/002),而它被packages/client/vitest.config.ts显式 exclude(需要真实服务器)。三个仓的产品代码没有任何一处读apiName。因为窗口期内两个键都发,该集成断言依然是绿的。一处需要说明的架构判断:为什么不是 ADR-0087 D2 转换表条目
D2 转换层作用于加载期的被授权(authored)元数据 ——
normalizeStackInput那个缝、flow.node.type/page.kind/object.compactLayout这一类键。响应载荷没有加载缝:discovery 每次请求现算,没有任何「存量形状」需要在加载时被改写,登记一条 D2 条目不会有任何代码去应用它。所以这里走的是本仓既有的 API 面退役先例:退役时间表写进声明处 + pin 测试(参照 #4939
registry-retirement.test.ts的做法)。同理,features/endpoints从来没有被声明过,不存在「从 schema 里删掉」这一步(retiredKey()的前提是键在 schema 的 shape 里),退役的动作就是「生产者停发 + 闸门钉死不许回潮」。packages/spec/liveness/同样不适用:它以注册表为根、走可授权元数据类型,discovery 不在其中。先证红
验收核心测试在未改生产者时确实按预期红,理由与预测一致。
getDiscovery()(裁定④,必填键):同一次运行里「不得发出未声明的键」那条是绿的 —— 这正是预测的方向:
getDiscovery()当时的键集(version/apiName/routes/services/capabilities)本来就都在允许集里,它的病是少键不是多键。多键的那一半病在 dispatcher 上,由 runtime 侧的同名测试覆盖。反向验证(方向在运行前先写下)
把
endpoints: routes和features: {...}装回 dispatcher,预测三条:undeclared = ['endpoints', 'features'];features/endpoints已退役」的 pin → 红;DiscoverySchema.safeParse→ 依然绿。第 3 条是最要紧的一条,也是本 PR 为什么不能只有验收核心那一条断言的原因:zod object 默认 strip 未知键,值层的
parse()根本看不见多余键。换句话说,裁定里写的验收核心(「两个生产者的线上形状都能通过DiscoverySchema.parse()严格校验」)对必填键那一半是充分的,对退役那一半是不充分的 —— 所以每个生产者的闸门是两条断言而不是一条。实跑结果贴在 PR 评论。测试
全绿,affected 包全量跑:
@objectstack/spec@objectstack/metadata-protocol@objectstack/rest@objectstack/runtimepackages/spec的生成物按 AGENTS.md 流程处理:build→check:generated(点名 3 个 stale)→--fix只重生成这 3 个,逐行复核过 ——authorable-surface.json只多Discovery:scoping/GetDiscoveryResponse:scoping两行,json-schema.manifest.json只多api/DiscoveryEnvironment,api-surface.json只多 3 个新导出(纯增,无删除),strictness ledger 的api/计数 393 → 394(新增scoping这个 z.object,方向正确)。无任何无关漂移。顺带一提:首次跑 spec 套件时有 18 条红,全部是 AGENTS.md §9 的陈旧生成物陷阱(
build-schemas-check-mode17 条 + strictness ledger 1 条),重生成后自行消失,与代码逻辑无关。顺带修正的 fixture
packages/runtime/src/http-dispatcher.test.ts的 8 处info.features.X改写为 canonical 的info.capabilities.X.enabled—— 它们是对生产者输出的真实断言,改拼法即可,覆盖面一点没少。packages/adapters/hono的 3 处 dispatcher mock 里endpoints: []改为routes: {}(本来就没有任何断言读它,改成真实生产者的拼法,免得下一个人照抄退役键)。范围外发现
均已另行开单、未认领,不在本 PR 修(Prime Directive #10):#5679、#5672、#5673、#5674、#5676。详见 issue 回复。
conversions/registry.ts未改动,与 #4923 无重叠。本 PR 同时是 #3877 Stage A 里点名的第一个族(discovery)的落地。
Generated by Claude Code