fix(platform-objects): 超预算后台 seed 期间不再空库自证 —— 一次启动不再跑两套契约 (#4795) - #6147
Conversation
…eed is in flight #4769 moved the fresh-datastore attestation off `kernel:ready` onto `app:seeded` — the settle point for this boot's own data — keeping `kernel:ready` as the backstop for kernels that never seed. The remaining window is that the two hooks can arrive in EITHER order: when AppPlugin's inline seed overruns `OS_INLINE_SEED_BUDGET_MS` it continues in the background, so `kernel:ready` lands first and the backstop certified the store mid-seed, flipping the value-shape gates to strict while the same seed run was still writing. One boot, two contracts. Measured on a showcase cold boot at `OS_INLINE_SEED_BUDGET_MS=1`: attestation at +0.470s, seed settled at +3.617s — a 3.147s window. Both hooks now ask whether this boot's seed has settled before certifying anything, `app:seeded` included: a multi-app bundle fires it once per app, so the first one is not the boot's settle point. The signal is a published `seed-settlement` contract rather than a sniff of the runtime's internal `seed-datasets` service. That array's presence says a seed source EXISTS; it can never say whether it has SETTLED, and the gap between those two facts is the whole window. The runtime declares each seed source before choosing what to do with it and settles it when the write is actually done. Posture for multi-tenant and `skipSeedData` (ruled 2026-08-06, #4795): both register seed datasets and deliberately do not write them at boot, so `app:seeded` never fires and the tally stays pending. They do not self-certify at boot and wait for `os migrate … --apply` to record the flag on a real scan — which falls out of the same predicate rather than needing a branch of its own. objectql carries a comment-only change: the background-seed ordering is no longer a scenario #4769's revocation mechanism has to catch, since it is now closed at the source. It stays live for the `os dev` hot-reload seeder, a runtime marketplace install, and the lax env-var escapes. Fixes #4795 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
…er-selfcert-inflight-seed
The merge driver defers generated artifacts rather than text-merging them (AGENTS.md §11); this is the regeneration from the merged tree. Restores main's `DriverQuery` entry alongside this branch's seed-settlement exports. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 4 package(s): 119 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
|
ACCEPT(执行席 PM 验收,冻结期收尾单) 核过:① 裁 A 完整落地 —— 「seed 已注册未结算 → 不自证」经由生产者发布的 seed-settlement 事实(pending/inFlight/suppressed),⛔ 未嗅 seed-datasets(必答项给出了为什么数组长度在 bug 存在的每一刻都读到相同值 —— 这是对「嗅探为什么错」的正确回答,不只是换了个名字);multi-tenant/skipSeedData 姿态按 10:41Z 终裁写明(suppressed 永不结算即答案),⛔ 未走 08:27Z 被覆盖的「空发 app:seeded」形态;② P1 实测复现(1ms 预算冷启,3.147s 双契约窗口),且对 issue 前提如实修正(该次 errored:0,伤害是不变量破坏而非必然丢行)—— 不粉饰;③ 双肢反向验证精确到条数命中(6/6 + 1/1,后者钉住 settle 必须早于 trigger 存在性判断的次序);④ 偏差 2(推迟判据同时作用于 app:seeded)接受 —— 多 config bundle 下只守 kernel:ready 等于把窗口平移,属同一不变量的完整实现,有专门测试;⑤ #4776 必答项质量高(时序绕法 + suppressed 维度的可照抄样本,核心难题如实保留);⑥ #4794 撤销注释同步更新,objectql 侧零行为变更。 跨席声明:本 PR 在 packages/spec 新增 seed-settlement 契约(+4 导出,api-surface 已重生成)—— 按 #6054 先例开 spec 席确认单(随后链接),声明为可选槽位形态、可事后收窄。维护者今日在线,已同步周知。 翻 ready + auto-merge,进队列(rc.5 口径)。 Generated by Claude Code |
Fixes #4795
裁决引用与层级说明
本单有两条处置意见,按后者执行:
session_01GcjbQLUQKysMU9uXB34iyvapp:seeded」session_01N3uGFF8teXbpgtbEJ1aYXu)app:seeded;multi-tenant / skipSeedData 的 ADR-0104 姿态按「不自证、等os migrate」写明。B(runtime 提供在飞信号)不单独立项,若 A 自然需要则作为 A 的内部件本 PR 按 10:41Z 裁定实现。两点推论已落实:
app:seeded」。那会让 multi-tenant / skipSeedData 在 boot 完成自证,与最终裁定的姿态直接矛盾。裁定允许的「内部件」用到了:runtime 侧新增
seed-settlement信号,作为 A 的实现部件,未单独立项。前提复核(派发前置:本单 08-03 立案后零核验记录)
P1 — 超预算转后台的窗口在
origin/main上仍存在 ✅ 成立(且实测)结构证据:
packages/runtime/src/app-plugin.ts预算竞速后仍走seedPromise.then(() => emitSeedSettled(true));packages/platform-objects/src/plugin.ts仍同时挂app:seeded与kernel:ready两个自证钩子。手证一次(
OS_INLINE_SEED_BUDGET_MS=1冷启 showcase,随机高位端口 41873,--fresh):自证落笔在
19.441,seed 结算在22.588—— 窗口 3.147s,期间闸门已翻 strict(20.151)。正文描述的链条在 main 上完整存在。一处如实修正:该次 showcase 运行
errored: 0,即尾部行没有真的被拒。窗口是真的、可测的,但「尾行被拒」取决于数据集里是否恰好有不合形状的值;showcase 当前的 seed 是干净的。也就是说本单的确切伤害是「同一次 seed 运行前后两套契约」这个不变量被破坏,而不是每次必然丢行 —— 与 08:27Z 处置评论「剩余伤害是 DX 噪音」的判断一致。P2 — multi-tenant / skipSeedData 下
app:seeded确实不触发 ✅ 成立AppPlugin.start()中skipSeedData与multiTenant两个分支在emitSeedSettled之前整体短路。仓内既有测试已钉住其一:app-plugin.seed.test.ts的does not run the inline seed (or emit) in multi-tenant mode。这正是「有 datasets 就等」会让这两类部署永不自证的原因,也正是本 PR 把它转成期望姿态而非缺陷的地方。P3 — #4794 撤销机制存在,且注释把「后台 seed 收尾晚于签发」列为覆盖场景 ✅ 成立(已按本 PR 更新)
packages/objectql/src/engine.ts的retractCreationAttestation()存在,原注释明文列出or a seed that finishes in the background after its budget。本 PR 让该句过时:这条次序已在源头关闭,不再需要撤销机制去兜。按任务要求「若你的改动让某句注释过时,更新它」,已改写为:仍然覆盖 lax 开关(
OS_ALLOW_LAX_*)、os dev热重载 seeder、运行期 marketplace 安装;并注明 boot inline seed 这一条已由 #4795 在源头关闭,此处退为安全网而非第一道防线。objectql 侧仅此注释改动,无行为变更。处置
落点与机制:
packages/spec/src/contracts/seed-settlement.ts(新) —— 发布seed-settlement契约:ISeedSettlementService.snapshot()返回{ pending, inFlight, suppressed },外加SEED_SETTLEMENT_SERVICE槽名与SeedSuppressionReason(multi-tenant-replay/skip-seed-data)。只读:声明与结算是 seeding host 自己的事,能改计数的消费者就能给自己发证。packages/runtime/src/seed-settlement.ts(新) —— 生产侧账本。declareSeedSource(ctx)按 audit: multi-tenant per-org seed replay silently omits marketplace packages (and any 2nd app) — same dead-ctx.kernel+ duplicate-registerServiceroot cause as #3452 #3453 的 register-once-then-mutate 纪律注册一次、后续源读回追加,返回幂等且终态的{ settle, suppress }句柄。packages/runtime/src/app-plugin.ts—— 在选择分支之前声明 seed 源;skipSeedData→suppress('skip-seed-data'),multi-tenant →suppress('multi-tenant-replay'),inline 路径在emitSeedSettled的最前面(早于trigger存在性判断)settle()。packages/platform-objects/src/plugin.ts—— 两个钩子都先问一句「本次启动自己的 seed 落定了吗」,pending > 0即不签发。三个设计点值得单独说:
app:seeded也受这道检查约束。 多 config app 的 bundle 会每个 app 触发一次,第一次并不是本次启动的结算点 —— 只守kernel:ready会把同样的分裂窗口平移到多 app bundle 上。wasDatastoreCreatedFromEmpty()之后。 已存在的库本来就不自证,在那里播报「推迟」是在解释一个根本没人做的决定。姿态写明位置(裁定要求的「写明」):
packages/platform-objects/src/plugin.ts→reportDeferral()的 TSDocos migrate」是可恢复方向、为何日志取info而非warnpackages/spec/src/contracts/seed-settlement.ts顶部 TSDocpackages/runtime/src/app-plugin.ts两个 suppress 分支旁.changeset/defer-adr0104-attestation-while-seed-in-flight.md运行期还会在
kernel:ready播报一次(info,并点名os migrate value-shapes --apply/os migrate files-to-references --apply)。取info不取warn是刻意的:这是功能性姿态而非持久化降级,没有任何「声称已落库却没落」的东西,且停住的是宽松那一侧的闸门;对每个 multi-tenant 部署每次启动都打warn报告一个设计上正确的行为,正是 AGENTS.md 说的「训练所有人跳过error/warn」。⛔ 未触碰
content/docs/releases/;未 rebase;未 force-push。测试与反向验证
新增测试
packages/platform-objects/src/plugin.test.ts—— 新增 describedefers while this boot own seed is still landing (#4795),9 条:kernel:ready writes no attestation while a seed source is still in flight—— 本单的钉子:in-flight 时kernel:ready不写行;state.inFlight = 0后app:seeded正常落笔两行。an app:seeded from one config app does not certify while another is still writing—— 多 app bundle。3-4.
it.eachmulti-tenant / skipSeedData:boot 不自证、不报错(姿态测试)。says why it stood down, and names the command that closes the gate—— 消息含multi-tenant-replay与os migrate value-shapes --apply,且不在warn出现。an in-flight deferral says it will be picked up on app:seeded。a settled seed attests at kernel:ready exactly as before—— 预算内正常路径回归。a kernel with no seed pipeline still attests on the kernel:ready backstop—— bug(objectql): ADR-0104「空库即已迁移」自证写在首启 seed 之前 —— 部署证明了一个它同一次启动就违反的契约,第二次pnpm dev起永久 10 条 ERROR #4769 兜底不变。says nothing about deferral on a store that already existed—— 噪音检查。packages/runtime/src/seed-settlement.test.ts(新,10 条):账本本身 —— 声明/结算/suppress、第二个源追加而非顶替(#3453 陷阱)、幂等终态(防双重 settle 把计数打成负数、假报「都落定了」)、快照为副本、无 registerService 的上下文不抛。packages/runtime/src/app-plugin.seed.test.ts—— 新增 describeseed-settlement signal (#4795),6 条:超预算源保持 pending 直到后台跑完、预算内 start() 返回时已结算、两种 suppress 形态、无 seed datasets 时根本不注册账本、无trigger()时仍结算。引擎替身:
platform-objects复用既有 fake,其update已由@objectstack/metadata-core的assertEngineUpdateDispatch守卫(本 PR 未新增写动词替身,delete未被任何路径触及故省略)。反向验证(方向先写死,再运行)
肢体 1 —— 删掉 platform-objects 的推迟判据(恢复旧兜底行为)。
7、8、9 在这 269 条里,未翻红 —— 说明这几条不是靠推迟判据「空过」而绿的。
肢体 2 —— 把
seedSource.settle()挪到typeof trigger !== 'function'提前返回之后。settles even when the kernel context has no trigger()。×。这条钉的是次序:结算必须早于
trigger存在性判断,否则没有trigger()的内核会把账本永久卡在 pending,让每个消费者等一个不会来的信号。命令与真实输出
合并
origin/main(24 个提交,触及packages/objectql/src/engine.ts、packages/spec/src/contracts/*、packages/spec/api-surface/contracts.json—— 与本 PR 重叠)后,在合并树上重跑:api-surface/contracts.json增量恰为本 PR 的 4 个导出(ISeedSettlementService/SEED_SETTLEMENT_SERVICE/SeedSettlementSnapshot/SeedSuppressionReason),合并后重新生成时一并恢复了 main 侧的DriverQuery。生成物按 AGENTS.md §11 由 merge driver 延迟、pre-commit 收账,提交时os-regen: all deferred artifacts are current — marker cleared。packages/spec因新增导出被动,已按 AGENTS.md §10 重建依赖链后完整重跑(上表即合并树上的结果)。必答项
#4776(注册表「尚未注册」与「没有提供方」同值)—— 定价变化?
变简单,并且出现了一个可照抄的样本。(只作答,未实现。)
#4776 的难点是「服务不在注册表里」这一个观察承载了两种事实:还没注册 与 这个部署根本没有提供方。本 PR 没有去消解这个歧义,而是绕开了它:把提问时刻挪到
kernel:ready(Phase 2 全部start()完成之后),于是「不在」在这个时刻只剩一种含义,歧义在时序上被消掉,不需要内核契约层面的区分。这对 #4776 的定价有两个方向的影响:
kernel:ready之后再问的消费者,都不需要 [决策] 启动期「提供方尚未注册」与「根本没有提供方」在注册表里是同一个值 —— 一次 showcase 冷启暴露 3 个同形缺陷,是否收紧 kernel 注册表契约 #4776 —— [决策] 启动期「提供方尚未注册」与「根本没有提供方」在注册表里是同一个值 —— 一次 showcase 冷启暴露 3 个同形缺陷,是否收紧 kernel 注册表契约 #4776 只需覆盖必须在 Phase 1/2 就下结论的那些位置。作用域变窄,定价变低。pending/inFlight/suppressed),把「不在」与「在但未结算」变成两个不同的可读值。[决策] 启动期「提供方尚未注册」与「根本没有提供方」在注册表里是同一个值 —— 一次 showcase 冷启暴露 3 个同形缺陷,是否收紧 kernel 注册表契约 #4776 若走「让缺席可自证」的路线,这就是一个已经跑通的参考实现;suppressed这一维尤其对口 —— 它正是「有提供方但这次不会来」的显式编码,而这恰是 [决策] 启动期「提供方尚未注册」与「根本没有提供方」在注册表里是同一个值 —— 一次 showcase 冷启暴露 3 个同形缺陷,是否收紧 kernel 注册表契约 #4776 抱怨同值的那两种事实之一。一句提醒:本 PR 的绕法依赖时序可推迟。这不是普适解,#4776 的核心难题(必须早问时怎么办)仍然原样存在。
#4797(lax 开关下证书变陈旧)—— 是否触其面?
否,符合预期。
#4797 讲的是证书签发之后因
OS_ALLOW_LAX_*放行的违规值而变陈旧,由retractCreationAttestation()撤销。本 PR 只改变签发时机(在飞时不签),不改变签发条件,也不改变撤销逻辑 —— objectql 侧仅动注释,零行为变更;packages/objectql2180 条全绿即为佐证。有一处间接关系值得记一笔,方向是减轻而非触碰:撤销机制原本要兜的场景之一(后台 seed 收尾晚于签发)现已在源头关闭,该机制在 lax 开关这条路径上的负担只减不增。#4797 的问题面本身未被触及。
#4795 正文提到的「嗅服务名」难点 —— 是否真的避开了?怎么避开的?
避开了。
正文的顾虑是:platform-objects 要判断「是否还有 seed 在飞」,就得去嗅 runtime 注册的
seed-datasets服务。本 PR 一行都没有读seed-datasets。关键不只是「换了个服务名」,而是换了一个能回答问题的事实:
seed-datasets是一个数组。它的存在只能说明「seed 源存在」,永远说明不了「已经结算」。而这两件事之间的差,恰恰就是本 bug 的整个窗口 —— 靠数组长度做判断,在 bug 发生的每一刻都读到相同的值。seed-settlement由生产者在状态真正改变的那一刻更新,发布的就是被问的那个事实。具体三点:
packages/spec/contracts,两侧同源导入,不是消费者对生产者内部结构的猜测(Prime Directive Add comprehensive test suite for Zod schema validation #12 contract-first)。platform-objects 的依赖只有@objectstack/spec与@objectstack/metadata-core,runtime 不依赖 platform-objects —— spec 是唯一能承载这个契约的位置,也确实放在了那里。SEED_SETTLEMENT_SERVICE,不是消费者手写的字符串字面量。kernel:ready是事实而非未定(所有 seed 源都在 Phase 2start()里声明),因此「没有该服务 → 本内核没有 seed 管线 → 走兜底自证」是可靠推断,不是把 not-yet 记成 verdict ——check:startup-registry-verdict在本 PR 后仍报 40 个 seam 全部合法。风险与残留
os migrate … --apply。这是裁定明确要的姿态,方向也是可恢复的那一侧(宽松),但确实改变了这两类部署的默认行为,已在 changeset 中面向消费者写明。seed-settlement.ts模块 TSDoc 中写明为刻意取舍。os dev热重载 seeder 与运行期 marketplace 安装不计入本账本(它们发生在自证点之后)。这按设计仍由 fix(objectql,platform-objects): 空库自证改在本次启动写完数据之后 —— 一次启动不能证明它随即违反的契约 (#4769) #4794 的撤销机制兜住,已在 engine.ts 更新后的注释中点名。Generated by Claude Code