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 个)

类型谁消费修复前修复后
apiobjectobjectstack serve → REST + dispatcher(含 enforceProjectMembership 每环境成员 403 闸门)单值:同值放行,冲突报错
serverobjectobjectstack serve → 入站限流器(security.rateLimit / trustProxy,#4910)单值:同值放行,冲突报错
runtimeModulestring构建产物的 ESM handler bundle单值:同值放行,冲突报错
functionsmap | arrayAppPlugin 启动绑定,声明式 hook / action / script 节点按名解析按名合并,重名报错
datasourceMappingarray数据源路由拼接
datasetsarray分析语义层(ADR-0021)拼接
jobsarrayIJobService 定时任务拼接
emailTemplatesarrayIEmailService.sendTemplate拼接
docsarray包文档(ADR-0046)拼接
booksarray文档导航(ADR-0046 §6)拼接
tiersarrayplugin 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 个数组集合 —— datasourcestranslationsobjectExtensionsappsviewspagesdashboardsreportsactionsthemesflowspositionspermissionscapabilitiessharingRulesapiswebhooksagentstoolsskillshooksmappingsanalyticsCubesconnectorsdatapluginsrequiresdevPlugins —— 拼接语义一字不变,由控制用例钉住。

三条规则

  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),混用报错并指示统一形态。

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

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

constCOMPOSE_KEY_DISPOSITIONS: Record<keyofObjectStackDefinition,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

vercelBot commented Aug 4, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 4, 2026 1:08am

Request Review

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

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.

…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 queueAug 4, 2026
Merged via the queue into main with commit a019e52Aug 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

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

@xuyushun441-sys@claude