Skip to content

composeStacks silently drops every non-array top-level key — api: today, server: as of #4910 #5005

Description

@xuyushun441-sys

发现于 #4910 开发过程(入站 rateLimit seam),与本单修复无关,按 Prime Directive #10 单独立单,未认领。

事实

composeStacks(packages/spec/src/stack.zod.ts)只合并三类东西:

  1. manifest —— 按 manifest 策略择一;
  2. i18n —— last-wins;
  3. objects + CONCAT_ARRAY_FIELDS 里列出的数组集合 —— 拼接。

其余一律不进 composed。它从一个空对象 {} 开始逐项填充,所以任何既不是 manifest/i18n、也不在数组清单里的顶层键,合成后直接消失,没有告警。

今天受影响的顶层键:

键 谁消费它 合成后
api(enableProjectScoping / projectResolution / enforceProjectMembership) objectstack serve(serve.ts),转发给 REST + dispatcher 丢
server(security.rateLimit / trustProxy,#4910 新增) objectstack serve → dispatcher 的入站限流器 丢
datasourceMapping 数据源路由 丢

datasourceMapping 尤其值得一看:它是数组,只是没被列进 CONCAT_ARRAY_FIELDS(需实测确认)。

为什么这是 declared ≠ enforced 的形状

作者写下 server.security.rateLimit,单栈下真限流;把同一个栈丢进 composeStacks([base, addon]),限流静默消失,defineStack 不报错、validate 不报错、启动日志也不会说少了什么 —— 因为消费方看到的就是 undefined,与「没写过」无法区分。这正是 #4686 那一类缺陷换了个入口。

api.enforceProjectMembership 走同一条路径,后果是每环境成员 403 闸门在合成栈里悄悄关掉,安全影响比限流更直接。

复现(未跑,依据是源码 —— 请开工时先实测确认)

const a = defineStack({ manifest, server: { security: { rateLimit: { enabled: true, maxRequests: 5 } } } });
const b = defineStack({ manifest });
composeStacks([a, b]).server   // → undefined

注意 composeStacks 的短路:stacks.length === 1 时原样返回,所以单元素合成看起来是好的,只有真正 ≥2 个栈才丢 —— 这也解释了为什么至今没被发现。

待裁决(不要猜)

顶层标量/对象键的合成语义没有先例可循,需要维护者定一次,而不是每个键各定各的:

  • A. last-wins(与 i18n 一致)—— 最省事,但 api / server 是安全配置,后一个包静默覆盖前一个包的限流预算是危险的默认。
  • B. 深合并 + 冲突报错 —— 两个栈都声明 server.security.rateLimit 且值不同 → composeStacks 抛错,和 objectConflict: 'error' 的既有姿态一致。
  • C. 显式策略参数(ComposeStacksOptions 加一项,默认 B) —— 最贵,但把选择权交给作者。

另有一个与语义无关、无论选哪个都该做的:合成时丢弃任何未处理的顶层键,应该至少 warn 一次并点名。今天它是完全静默的,这才是真正让人查不出来的部分 —— 定了语义之后,凡是新增顶层键忘了接进 composeStacks 的,也会立刻自曝,而不是等下一次有人做 #4910 这样的活儿时偶然撞见。

关联:#4910(引入 server: 的单)、#4686(三份 RateLimitConfig 零 reader)、Prime Directive #10 / #12。

Activity

  1. xuyushun441-sys commented on Aug 4, 2026

    @xuyushun441-sys
    CollaboratorAuthor

    维护者裁决(2026-08-04):冲突即报错 + 未处理键必警

    • 组合语义:同值放行;冲突报错,错误信息带处方(点名冲突键与两个来源栈,指示显式指定保留哪个或改一致)。⛔ 不做 last-wins(静默安全降级——先声明 403 门的栈被后组合者无声覆盖,正是本单 api: 被丢的孪生形态)、不做 deep-merge(制造新的静默歧义类)。
    • 未处理顶层键必须 warn —— 恢复不变量部分,实现时与报错语义同 PR 落(下一个新顶层键自己报告,不再靠 「v17」入站 rateLimit 接执行:ApiEndpoint / HttpServer 的 RateLimitConfig 推导为 runtime token bucket 配置,dispatcher 生效(#4686 拆向之一) #4910 式偶然发现)。
    • 显式覆盖机制(客户定制 vendor 包场景)留给定制故事真拉动时设计,本单不预支。
    • 业务定性:组合栈是应用打包/安装的商业承载,静默丢键是信任级 bug——修复优先级列本轮派发队列首位。

    needs-user-decision 摘除,入队。


    Generated by Claude Code

  2. xuyushun441-sys commented on Aug 4, 2026

    @xuyushun441-sys
    CollaboratorAuthor

    🔒 认领:PM 循环派发(批 15 验收后空位,裁决执行队列首位——活 bug)
    会话:session_01Ehu85kbvMcrNTUJjwxvLJ9
    分支:claude/issue-5005-compose-stacks-merge-semantics
    Worktree:objectstack-issue-5005
    域:domain:engine
    文件面:composeStacks 实现及其测试(objectql/runtime,按实测定位)、changeset。⛔ 不碰 packages/spec 源(若发现需要 spec 侧配合的新契约问题,needs_decision 不猜)。

    按 2026-08-04 裁决执行:同值放行、冲突报错带处方、未处理顶层键必警;⛔ 不做 last-wins / deep-merge;显式覆盖机制不预支。与在飞批 14(spec/ui)、#4956(spec scripts)文件面不相交。


    Generated by Claude Code

  3. xuyushun441-sys commented on Aug 4, 2026

    @xuyushun441-sys
    CollaboratorAuthor

    ✅ 验收通过 —— PR #5053(已挂轨)

    复核实况(3 文件;stack.zod.ts 零 zod schema 定义变更实查;authorable/api-surface 零漂移;⛔ releases/ 零触碰):

    • 位置修正接受:composeStacks 实住 packages/spec/src/stack.zod.ts:1413 而非派发词假设的引擎侧。dev 编辑 spec 源但保持契约字节级中性并显著申明偏差——fix(spec): make the bulk-action option item's openness deliberate, not accidental (#4001) #4909 诚实偏差先例,比空转一轮 needs_decision 正确。
    • 修法超裁决要求:「未处理键必警」升级为编译期不通过——disposition 表 Record<keyof ObjectStackDefinition, ComposeDisposition> 全覆盖,新顶层键不声明组合语义即 TS2741(删表项实证)。CONCAT_ARRAY_FIELDS 由表推导,手工白名单退役。
    • 爆炸半径实测 3 → 11 键:functions 被丢意味着组合栈的全部声明式 handler 启动静默失败——比立单点名的限流丢失更重;七个声明数组集合(datasets/jobs/emailTemplates/docs/books/tiers/datasourceMapping)同样在漏。ADR-0109 曾按键打补丁(tools)正是白名单模式的病历。
    • RED-first 复现立单证据(api/server 丢失断言先红,16 红 6 绿 → 实现后 66 全绿);既有 44 例组合测试零改动全绿;functions 按名合并、重名报错、map/array 各保形态不互转。
    • 衍生 composeStacks 的 i18n 仍是 last-wins —— #5005 裁决否掉的那个形状,只剩这一个键还在用 #5051(i18n last-wins 是仅存不一致键)已标 v18 地图——翻译叠加可能正是设计意图,无活伤害不进急件区。

    Generated by Claude Code

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

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions