Skip to content

fix(spec)!: composeStacks 不再静默丢弃顶层键 —— 同值放行 / 冲突报错 / 未声明必警 (#5005) - #5053

Merged
xuyushun441-sys merged 2 commits into
mainfrom
claude/issue-5005-compose-stacks-merge-semantics
Aug 4, 2026
Merged

fix(spec)!: composeStacks 不再静默丢弃顶层键 —— 同值放行 / 冲突报错 / 未声明必警 (#5005)#5053
xuyushun441-sys merged 2 commits into
mainfrom
claude/issue-5005-compose-stacks-merge-semantics

Conversation

@xuyushun441-sys

Copy link
Copy Markdown
Contributor

Fixes #5005

按维护者 2026-08-04 在 #5005 上的裁决实现:同值放行、冲突报错带处方、未声明规则的顶层键必警。⛔ 不做 last-wins、⛔ 不做 deep-merge、⛔ 不预支显式覆盖机制。

定位更正

派单假设 composeStacks 在 engine 侧(objectql/runtime),实测不是:它在 packages/spec/src/stack.zod.ts:1413,仓内除 packages/spec/src/index.ts 的 re-export 外零调用点。所以本 PR 落在 packages/spec 源上 —— 但没有动任何 Zod schema:ObjectStackDefinitionSchema / ComposeStacksOptionsSchema 一字未改,check:authorable-surface 实测零漂移,新增符号全部不导出(api-surface.json 不受影响)。改的只是那个函数的行为。

病灶

composeStacks 从空对象 {} 开始逐项填充:manifesti18nobjects,再加一份手工维护的数组白名单 CONCAT_ARRAY_FIELDS。不在白名单里的顶层键不是「原样保留」,而是被删除 —— 不报错、不告警,消费方拿到的 undefined 与「作者从没写过」无法区分。

stacks.length === 1 时函数原样返回,所以单栈一切正常,只有真正 ≥2 个栈才丢 —— 这是它至今没被发现的原因。ADR-0109 当年也只是给 tools 补了一行白名单,并没有堵住这一类。

顶层键逐项处置(修复前 → 修复后)

ObjectStackDefinitionSchema 今天声明 42 个顶层键,全部列在下面。

修复前会被丢掉的(11 个)

类型 谁消费 修复前 修复后
api object objectstack serve → REST + dispatcher(含 enforceProjectMembership 每环境成员 403 闸门) 单值:同值放行,冲突报错
server object objectstack serve → 入站限流器(security.rateLimit / trustProxy,#4910) 单值:同值放行,冲突报错
runtimeModule string 构建产物的 ESM handler bundle 单值:同值放行,冲突报错
functions map | array AppPlugin 启动绑定,声明式 hook / action / script 节点按名解析 按名合并,重名报错
datasourceMapping array 数据源路由 拼接
datasets array 分析语义层(ADR-0021) 拼接
jobs array IJobService 定时任务 拼接
emailTemplates array IEmailService.sendTemplate 拼接
docs array 包文档(ADR-0046) 拼接
books array 文档导航(ADR-0046 §6) 拼接
tiers array plugin tier 预设 拼接

datasourceMapping 正是 issue 里那条「它数组,只是没被列进 CONCAT_ARRAY_FIELDS(需实测确认)」—— 实测确认成立,另外 6 个数组键同病。

行为不变的(31 个)

  • manifest —— 仍按 manifest 选项择一('first' / 'last' / 索引)。
  • objects —— 仍按 objectConflict 策略(error / override / merge)。
  • i18n —— 保留既有 last-wins。它是这里唯一本来就有明确、可工作策略的键,而本单主题是「被丢掉的键」,改它会打断今天真在依赖它的组合。改完后它成了顶层键面上唯一的不一致,已单独立单:composeStacksi18n 仍是 last-wins —— #5005 裁决否掉的那个形状,只剩这一个键还在用 #5051(未认领,附 A/B/C 三个选项)。
  • 其余 28 个数组集合 —— datasources translations objectExtensions apps views pages dashboards reports actions themes flows positions permissions capabilities sharingRules apis webhooks agents tools skills hooks mappings analyticsCubes connectors data plugins requires devPlugins —— 拼接语义一字不变,由控制用例钉住。

三条规则

  1. 同值放行 —— 多个栈声明同一个单值顶层键且深相等,照常合成(undefined 视同未声明)。

  2. 冲突报错,信息点名冲突键、两个来源栈(manifest id,无 manifest 时退化为 stack #N)与两条出路:

    composeStacks conflict: top-level key 'api' is declared with different values by
    'com.example.base' (stack #0) and 'com.example.addon' (stack #1).
    composeStacks does not pick a winner for single-valued top-level configuration:
    overriding would silently disable whichever stack declared the stricter setting
    (an 'api.enforceProjectMembership' 403 gate, a 'server.security.rateLimit' budget),
    and deep-merging would produce a value neither stack declared.
    Fix: make the two 'api' declarations identical, or remove it from every stack
    except the one that should own it.
    
  3. 未声明规则的顶层键必警 —— 按默认规则合成(数组拼接,其余按单值规则)点名告警指向 composeStacks silently drops every non-array top-level key — api: today, server: as of #4910 #5005,而不是消失。同一个键 warn 一次,与本模块其它 authoring-time 提示同姿态。

functions 单独说明:它是命名集合,不是不透明配置块 —— 组合 CRM + Todo 必须两边的 handler 都在,否则每个按名解析 handler 的声明式 hook / action / script 节点在启动时全断。所以按名合并,重名报错(与 objectConflict: 'error' 一致)。map 与 array 两种书写形态各自同形合并,不互转(array 条目带 packageId,map 条目没有位置放它,转换会丢 provenance),混用报错并指示统一形态。

结构性保证:白名单 → 全量处置表

白名单让「忘记」成为默认。替换成的处置表类型是:

const COMPOSE_KEY_DISPOSITIONS: Record< keyof ObjectStackDefinition, ComposeDisposition > = { ... };

新增一个顶层键而没说清它怎么合成,tsc --noEmit 直接不过。实测(临时删掉 server: 'single' 一行):

src/stack.zod.ts(1325,7): error TS2741: Property 'server' is missing in type
'{ manifest: "manifest"; ...; runtimeModule: "single"; }' but required in type
'Record< "capabilities" | "functions" | ... | "runtimeModule", ComposeDisposition >'.

CONCAT_ARRAY_FIELDS 现在从这张表派生,两者不可能再漂移。运行时那条 warn 兜住类型看不见的入口(strict: false、手搓 stack 对象)。

一个补齐的边角:声明为集合的键却携带非数组值时,过去也是静默跳过 —— 同一个缺陷的缩小版,现在同样 warn(仅 strict: false / 手搓对象可达,strict defineStack 在书写处就拒绝)。

爆炸半径

  • 仓内真实组合:零composeStackspackages/spec/src/index.ts 的 re-export 外没有任何调用点(examples / platform apps / tests 之外)—— 实测 grep -rn composeStacks 只命中文档(content/docs/getting-started/examples.mdxskills/objectstack-platform/SKILL.md)、ADR-0109 与 CHANGELOG。没有任何真实组合依赖旧的静默行为,因此没有需要顺带修正的组合。
  • 既有 packages/spec/src/compose-stacks.test.ts 44 例全绿未改一行
  • 生成物零漂移:未改任何 Zod schema,check:authorable-surface 通过;新增符号全部不导出,api-surface.json 不变(check:api-surface 在本 worktree 因 dist/ 未构建而无法运行,已用「新增行零 export」实测替代)。

测试

RED-first。新用例文件 packages/spec/src/compose-stacks-key-loss.test.ts 先在未改动的 origin/main 形状代码上跑,复现 issue 自带的证据:

× keeps `api` when only one stack declares it (the issue's own repro)
  AssertionError: expected undefined to deeply equal { enforceProjectMembership: true }
× keeps `server` when only one stack declares it (#4910 rate limiting)
  AssertionError: expected undefined to deeply equal { security: { rateLimit: { …(2) } } }
Tests  16 failed | 6 passed (22)

实现后:

src/compose-stacks-key-loss.test.ts  22 passed
src/compose-stacks.test.ts (既有,未改) 44 passed
packages/spec 全量:Test Files 299 passed (299) | Tests 7590 passed (7590)
pnpm --filter @objectstack/spec typecheck  → tsc --noEmit 通过
eslint (改动的两个文件) → 无输出

覆盖:issue 原始复现(api / server 单侧声明存活,且与顺序无关)、同值深相等放行且不 warn、冲突报错点名键 + 两个栈 + 处方、 last-wins(两个方向都报错)、 deep-merge(不相交子键仍报错)、无 manifest 时退化为 stack #N、未知键 warn 且仍被合成、未知数组键默认拼接、集合键携非数组值 warn、数组拼接控制用例、7 个曾被丢弃的数组键、functions 按名合并与重名报错、manifest/objects/i18n 三个既有策略的控制用例,以及一条结构钉:声明了每一个 schema 顶层键的栈组合后零丢失且零告警(有新键没接进来时,它自己在这里报出名字)。

Changeset

.changeset/compose-stacks-no-silent-key-loss.md —— @objectstack/spec: major。破坏性在于:组合两个对 api / server / runtimeModule 声明了不同值的栈,过去静默丢弃、现在抛错(functions 重名同理)。这正是要的 —— 过去那次「成功」的组合,产出的是一个少了 403 闸门或少了 handler 的栈。v17 窗口开着(.changeset/pre.json 实测 mode: pre / tag: rc),与仓内在飞的 72 份 @objectstack/spec: major changeset 同惯例。

https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9


Generated by Claude Code

)

composeStacks built its result from an empty object, filling in manifest,
i18n, objects and a hand-maintained array whitelist. Anything absent from
that whitelist was DELETED — no error, no warning, and `undefined` at the
consumer is indistinguishable from "the author never wrote it".

Composition is the platform's app-packaging/install story, so the silence
reached security config: `api.enforceProjectMembership` (the per-environment
403 gate) and `server.security.rateLimit` (#4910) both vanished the moment a
stack was composed with any other one, as did `functions` (every declarative
handler), seven declared array collections (datasourceMapping, datasets,
jobs, emailTemplates, docs, books, tiers) and `runtimeModule`.

Per the 2026-08-04 maintainer verdict:

1. same value in several stacks composes fine (deep equality);
2. differing values THROW, naming the key, both source stacks and the two
   ways out — NOT last-wins (a silent security downgrade: an add-on package
   switching off an earlier stack's 403 gate) and NOT deep-merge (a third
   value neither author wrote);
3. a top-level key with no declared composition rule WARNS and is composed
   by the default, so the next new key reports itself instead of being
   found by accident the way `server:` was.

Array keys keep their concat semantics unchanged. `functions` merges by
name (composing CRM + Todo must yield both packages' handlers) and throws
on a duplicate name; the map and array forms are merged in kind, never
converted (an array entry carries `packageId` the map entry cannot hold).
`i18n` keeps its pre-existing last-wins — it is the one key here that
already had a working strategy, and #5005's subject is keys that were
dropped.

The whitelist made forgetting the default; the replacement is a total
disposition table typed `Record< keyof ObjectStackDefinition, ... >`, so a
new top-level key does not compile until someone states what composing it
means. The runtime warn covers what the type cannot see (`strict: false`,
hand-built stack objects).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 4, 2026 1:08am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec.

107 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added the size/l label Aug 4, 2026
…elpers

The warn-helper insertion left `warnUncomposedStackKey`'s docblock attached
to `warnedMalformedCollectionKeys`. Comment-only; no behaviour change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ehu85kbvMcrNTUJjwxvLJ9
@xuyushun441-sys
xuyushun441-sys marked this pull request as ready for review August 4, 2026 01:30
@xuyushun441-sys
xuyushun441-sys added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit a019e52 Aug 4, 2026
25 checks passed
@xuyushun441-sys
xuyushun441-sys deleted the claude/issue-5005-compose-stacks-merge-semantics branch August 4, 2026 01:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants