Skip to content

fix(seed): enforce Seed.env against the runtime environment - #4836

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-4704-seed-env-enforced
Aug 3, 2026
Merged

fix(seed): enforce Seed.env against the runtime environment#4836
os-zhuang merged 1 commit into
mainfrom
claude/issue-4704-seed-env-enforced

Conversation

@claude

@claude claude Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Fixes #4704

一、先核实:issue 描述完全属实

按 PM 要求,先验证再动手。三条都逐一在代码里核对过:

1. filterByEnv 的实际行为 —— 与 issue 引用的一字不差(packages/metadata-protocol/src/seed-loader.ts,修复前第 1355 行):

private filterByEnv(datasets: Seed[], env?: string): Seed[] {
  if (!env) return datasets;
  return datasets.filter(d => (d.env as string[]).includes(env));
}

2. SeedLoaderConfig.env 确实从未被填充 —— 全仓一共 六处 构造 SeedLoaderRequest 的地方,没有一处传 env:

# 位置 场景
1 packages/runtime/src/app-plugin.ts :974 每租户 seed 重放
2 packages/runtime/src/app-plugin.ts :1042 启动内联 seed
3 packages/runtime/src/app-plugin.ts :1234 热重载 seed
4 packages/runtime/src/domains/packages.ts :677 package seed apply
5 packages/metadata-protocol/src/protocol.ts :7782 发布 seed 草稿
6 packages/cloud-connection/src/marketplace-install-local-plugin.ts :1037 市场安装

所以 config.env 恒为 undefinedfilterByEnv 第一行直接返回全量 → dataset.env 一次都没被读到

3. 补充一条 issue 没提、但值得记录的证据:packages/spec/liveness/seed.jsonenv 被标为 "status": "live",证据指向 seed-loader.ts:91(即消费端那一行),备注写着「filterByEnv drops datasets whose env list excludes the running environment」。这正是 AGENTS.md 第 10 条警告的那个陷阱 —— case 标签不是 enforcement,要看调用点」。台账核对了「消费端代码存在」,没核对「生产者是否真的传值」,于是这个洞在绿灯下活了下来。(该文件在 packages/spec/**,本轮零改动车道,我没碰;建议由 spec 车道把这条备注改成指向调用点的表述。)

同族证据:packages/runtime/src/seed-loader.test.ts:327 早就有一个 should handle environment filtering 测试 —— 但它 显式传了 env: 'prod'。所以过滤机制一直是好的,坏的只有接线。这也解释了为什么单测全绿而线上失效。

二、修法:在唯一的漏斗处解析,而不是在六个调用点

环境解析放进 SeedLoaderService.load() —— 所有 seeding 路径的唯一必经之处。

之所以不选「在六个调用点各自传 env」:这个 bug 的成因就是调用点漏传。在调用点补齐,只是把洞推迟到第七个调用点(将来任何新增 seeding 路径)重新打开。放在漏斗处,则新调用点结构上不可能漏掉 —— 这也是「让 AI 写的代码难以写错」的方向。

环境来源复用仓内既有的那一个:NODE_ENV 没有新造环境变量,也没有新增可声明键。理由是它已经是全仓的事实标准,而且两条一方启动路径都会把它钉死:

  • packages/cli/src/commands/start.ts :239 —— if (!localEnv.NODE_ENV) localEnv.NODE_ENV = 'production'(生产)
  • packages/cli/src/commands/serve.ts :455 —— process.env.NODE_ENV = 'development'(os dev / serve --dev)
  • vitest 自动置 test

其余环境敏感行为(auto-DDL 关闭、api-registry 生产守卫、sqlite 降级、热重载 seeder)也都在读它。

映射:production/prodprod,development/devdev,testtest(大小写不敏感)。接受 prod/dev 简写,是因为 NODE_ENV 属于运维提供的第三方边界变量(Prime Directive #9 明确把 NODE_ENV 列为第三方例外),不是我们自己的元数据契约 —— 这与「不要在消费端加宽容 fallback」不冲突。

优先级:宿主显式传的 config.env 永远优先(这是文档化的 escape hatch,也是「以另一个环境的身份种数据」的唯一手段)。

三、环境无法确定时:放行 + 响亮告知(fail-open)

这一条按 PM 要求给出理由。结论与 PM 倾向一致,但理由不止「向后兼容」:

决定性的一条是伤害不对称。 一刀切 fail-closed 不只会拦下 env: ['dev'],同样会拦下 env: ['prod'] —— 也就是说,一台只是忘了 export NODE_ENV生产主机,会静默丢掉本该种下的生产数据。那是数据缺失,比多种一批 dev 数据更糟,而且更难诊断。

其次,不确定窗口在结构上很窄:两条一方启动路径都会钉死 NODE_ENV(见上),vitest 也会。真正落进这个窗口的,基本只有直接嵌入 kernel 的宿主。

第三,它现在不再是静默的。当且仅当确实存在收窄了 env 的数据集时,打一条 warn,点名每个数据集、给出可接受的 NODE_ENV 取值、并指出 config.env 这条出路。没有任何数据集收窄时保持安静(不制造噪音)。

诚实说明残留风险:一台 NODE_ENV 未设的嵌入式生产宿主,仍然会种下 env: ['dev'] 的数据 —— 但从此有一条明确的告警,而不是完全无声。这就是这次取舍的全部代价。

被过滤掉的数据集一律点名(info 级)。用 info 而非 warn,是因为在生产跳过 dev 数据集是声明所要求的正确行为,每次生产启动都 warn 属于狼来了;但它必须可查 —— 「我的 demo 数据怎么没了」应该是一行日志就能回答的问题。

另外,解析出的环境同时绑定给 seed CEL 的 env(baseEvalCtx.env 此前同样恒为 undefined),避免出现「过滤器按 prod 走、表达式看到 undefined」的分裂状态。

四、验收对照

四种情况分别有独立测试,且可相互区分(packages/metadata-protocol/src/seed-loader-env-scope.test.ts,13 个用例):

  • env: ['dev'] 在 dev 种、在 production 不种 —— 两条独立断言,外加一条 produces a different result in production than in development 显式断言两者结果必须不同(防止「全丢」或「全放」任何一种退化通过其中一条)。
  • 未声明 env / 声明为 schema 默认值的数据集,在 production / development / test 三个环境都种 —— 行为零变化。
  • 环境无法确定:全部种下 + warn 点名 + 含 NODE_ENVconfig.env 两处补救;而「无法确定但无人收窄」时保持安静,两者分别断言。
  • 一条 distinguishes filtered, permissive-and-warned, and clean loads 把三种结局的 (种下对象集合, 是否告警) 元组一次性钉死。

反向验证:把修复代码 stash 掉、只保留测试跑一遍,13 个里 8 个失败,首条失败正是 issue 的复现 —— expected [ 'account', 'demo_user' ] to deeply equal [ 'account' ](production 下 demo 数据仍被种下)。其余 5 个通过的正是向后兼容那几条,符合预期。

五、范围

  • 改动仅 packages/metadata-protocol/src/seed-loader.ts + 新增测试 + changeset。
  • packages/spec/** 零改动 —— Seed.envSeedLoaderConfig.env 的 schema 都无需改动,本次是纯接线修复。
  • 未新增任何包根导出(index.ts 只导出 SeedLoaderService,未动),故 changeset 定级 patch
  • 未碰 packages/lintskills/**content/docs/**content/docs/releases/,也未碰本轮其它 agent 的文件面。

Generated by Claude Code

`Seed.env` was authorable, defaulted and type-checked, but inert.
`SeedLoaderService.load` filtered on the LOADER CONFIG's `env`, and none
of the six call sites that build a `SeedLoaderRequest` (app boot, per-org
replay, hot reload, package apply, draft publish, marketplace install)
ever passed one — so `config.env` was always undefined, `filterByEnv`
short-circuited, and `dataset.env` was never read at all. A dataset
marked `env: ['dev']` seeded into production exactly as if it were
marked `['prod']`.

Resolve the environment in the loader — the one funnel every seeding
path goes through — from NODE_ENV, the environment source this repo
already uses everywhere (`os start` defaults it to production, `os dev`
sets development, vitest sets test). No new env var, no new authorable
key. An explicit `config.env` still wins.

When the environment cannot be determined, stay permissive (seed
everything, as before) but WARN, naming each environment-scoped dataset
and the remedy. Fail-closed would also drop `env: ['prod']` datasets on
a host that merely forgot to export NODE_ENV — silent data loss worse
than the over-seeding it prevents. Skipped datasets are always named in
an info log.

Fixes #4704

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

vercel Bot commented Aug 3, 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 3, 2026 9:37am

Request Review

@github-actions github-actions Bot added the size/m label Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol.

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

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata-protocol)
  • content/docs/releases/v9.mdx (via @objectstack/metadata-protocol)

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 documentation Improvements or additions to documentation tests tooling labels Aug 3, 2026
@claude

claude Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

范围外发现(Prime Directive #10)已单独立单,未指派,不在本 PR 修改:


Generated by Claude Code

Copy link
Copy Markdown
Contributor

复核通过 —— ACCEPT,已标 ready 并送合并队列

1. 修在漏斗处,不是修在六个调用点 —— 这是本 PR 最关键的判断,而且理由写进了代码。

Gating at those call sites instead would leave call site seven free to re-open the same hole.

一针见血。这个 bug 的成因就是调用点漏传;在调用点各补一遍,等于把洞推迟到第七个调用点。

2. 环境来源复用 NODE_ENV,没有新造。 这是我派发时的硬约束:若为此新增 OS_SEED_ENV 或新可声明键,等于把一个 declared ≠ enforced 换成另一个。dev 核实了仓内既有做法(os start 钉 production、os dev 钉 development、vitest 钉 test,且 auto-DDL / api-registry 生产守卫 / sqlite 降级 / 热重载 seeder 全都已经在分支 NODE_ENV),复用了它。

同时接受 prod / dev 拼写并在注释里区分清楚:这是对操作员提供的第三方变量做归一化(Prime Directive #9 明确把 NODE_ENV 列为此类),不是对我们自己元数据契约的消费端容忍。这个区分是对的——否则就成了 ADR-0018 警告的开放词汇。

3. fail-open 的取舍我认可,因为伤害确实不对称。 环境判不出时(NODE_ENV 未设,或值为 staging)保持宽松、全部种下,但响亮 warn 并点名每个被影响的数据集。一刀切 fail-closed 会连 env:['prod'] 的数据集一起拦下——让一台只是忘了 export NODE_ENV 的生产主机静默丢失本该种下的数据,比多种一批 dev 数据更糟。

而且 warn 只在确有数据集收窄了范围时才发(isEnvScopedDataset),不是每次启动都喊。不喊狼来了,这一点很重要。

4. 测试把"三种结局分开钉住"了。 这是本单最容易糊弄的地方:只证明「prod 下 dev 数据集不种」的话,一个「什么都不种」的实现同样能过。测试里那条 produces a different result in production than in development 正是反空洞断言。反向验证(把修复 stash 掉)8 failed | 5 passed,且通过的 5 条正是向后兼容项——这个分布本身就说明测试分层是对的。

5. changeset patch 正确。 查了同族先例:ADR-0105 D9(refuse organization on directory-less approver types,同样是"声明的东西开始生效")是 patch;仓内标 major 的是移除/退役可声明键那一类。本单无 API 变化、无 schema 变化、无新增导出,patch 与既有做法一致。

6. 顺带修掉的分裂值得表扬:解析出的环境同时绑定给 seed CEL 的 env,消除了「过滤器按 prod 走、表达式看到 undefined」这种两套答案的状态。issue 没要求,但不做的话就是留了个新坑。

一处我核实过、结论是无需处理的

我查了 content/docs/** 里是否已有文档声称 Seed.env 生效——这是我今天已经抓到过两次的模式(authorization.mdxauthentication.mdx 都曾把未落地的能力写成已落地)。结论:只有 references/data/seed-loader.mdx生成式类型表列出了 env 的形状,没有任何手写文档声称它被强制执行。所以本次不存在"文档先于实现说了大话"的情况,无需跟进。

范围外发现

#4837(liveness 台账把「消费端存在读取代码」当作 live 证据,漏掉「无任何生产者传值」的死键)已单独立单、未指派、属 packages/spec/** 车道,本 PR 零改动 —— 处理方式正确。

这条发现本身比它修的 bug 更有价值:台账核对了「消费端代码存在」,没核对「生产者是否真的传值」,于是这个洞在全绿 gate 下活了下来。同族佐证也找得很准——runtime/src/seed-loader.test.ts:327should handle environment filtering 长期通过,只因它自己显式传了 config.env='prod',恰好绕开了真实缺陷。一条一直在通过的测试,掩护了一个从未生效的键。

⚠️ 若合并队列把本 PR 踢出,大概率是 #4796 那条 spec flaky(今晚已 5 次命中无关 PR),止血单 #4850 在飞。届时原样重投,不必返工。


Generated by Claude Code

Merged via the queue into main with commit 0657f6b Aug 3, 2026
21 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-4704-seed-env-enforced branch August 3, 2026 10:19
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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Seed.env is authorable but never enforced: the app seeding path never sets SeedLoaderConfig.env, so env: ['dev'] seeds into production too

2 participants