fix(objectql,platform-objects): 空库自证改在本次启动写完数据之后 —— 一次启动不能证明它随即违反的契约 (#4769) - #4794
Merged
Merged
Conversation
…'s own data, not on the emptiness it remembers (#4769) A store created from empty recorded both ADR-0104 migrations as verified at `kernel:ready`, while the same boot was still seeding rows whose values contradict them. The certificate was true when written and false a second later: the first boot ran warn-first and kept the data, every later boot read the certificate, enforced it, and rejected the rows its predecessor had written. Same data, same code, one restart. Three changes, one invariant — a boot may not prove a contract it violates in that same boot: - The attestation waits for `app:seeded` (the inline seed's settle point, background continuation included), with `kernel:ready` as the backstop for kernels that never seed. Both enter the same idempotent call. - `attestFreshDatastore` consults the engine's tally of ADMITTED value-shape violations first and declines any migration id this boot has already contradicted, naming the object.field and the command that closes the gate. The two ids are judged independently — a bad `cover` does not sink the `location` gate. The engine records those admissions from the warn-first path with the exact predicate strict mode uses, so certifying still needs a scan while refuting needs the one counterexample the write already computed. - A certificate contradicted AFTER it was issued is revoked from the write path that contradicted it, scoped to creation attestations on a store this boot created; evidence produced by a real migration run is never rewritten. Also fixes the memoized flag read, the other half of why the first boot looked green: "the ledger says no" is now distinguished from "the ledger could not be asked" (sys_migration not registered yet, or the query threw). Both keep the gate closed, only the former is remembered, so one unlucky early write no longer freezes a whole boot's posture. The `kernel:bootstrapped` advisory reads the ledger directly for the same reason. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
…0104-attest-after-seed
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 14 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 3, 2026 07:12
This was referenced Aug 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4769
问题
全新部署第一次
pnpm dev全绿(130 rows,0 ERROR),第二次启动开始永久 10 条 ERROR,10 条种子记录写不进去。数据没变、代码没变,只是重启了一次 —— 被拒的正是首启自己写进去的数据。根因不是哪个值算错了,是顺序反了。
sys_migration里那两行带着{"attested":"datastore-created-empty"}的自证写在kernel:ready,而同一次启动的 seed 还在往库里写行。「空库 ⇒ 没有历史值」这个推理的前提是没有数据可写,它却恰恰写在即将写入 130 行之前:证书落笔那一刻是真的,一秒之后就不是了。首启在 warn-first 下把数据留下,之后每一次启动读到这张证书、进入 strict、拒掉前任写下的那批行。按 PM 裁定取修法 1(推迟到首启 seed 之后 + 对落定的数据重新下结论)。修法 2 只是把 insert/update 的不对称抹平成一样松或一样严,不触及真正坏掉的东西:证据在被推翻之前就被记账。
改了什么
一条不变量,三个位置:一次启动不能证明一个它在同一次启动里违反的契约。
① 写入时机 —— 等本次启动自己的数据落定。
自证从
kernel:ready改为订阅app:seeded(inline seed 的结算点,含超出OS_INLINE_SEED_BUDGET_MS后台跑完的那一半);不 seed 的 kernel 仍由kernel:ready兜底。两条路径进的是同一个幂等调用:先到的那个写行,后到的读到行就跳过。② 写入前提 —— 先问这次启动放行过什么。
attestFreshDatastore先读引擎的「已放行违规值」台账,任何被本次启动证伪的 migration id 不再自证;部署维持 warn-first(真实、可恢复),并在日志里指名是哪个对象.字段、该跑哪条os migrate才能真正关闸。台账由写路径产生:引擎在 warn-first 放行每一个不合形状的值时,用与 strict 模式完全相同的判定把它记下来。这是刻意的不对称 —— 证明一个部署干净需要扫全库,证伪只需要一个反例,而这个反例写路径本来就已经算出来了,所以它零成本、精确,且不可能与将来的强制判定漂移。
adr-0104-file-references与adr-0104-value-shapes各自独立判定:一个cover不合形状不牵连location那道闸,反之亦然(两条 migration 声称的是两件不同的事)。③ 写入之后 —— 被推翻就撤销。
证书若在签发之后被本次启动推翻(操作员显式开了
OS_ALLOW_LAX_MEDIA_VALUES/OS_ALLOW_LAX_VALUE_SHAPES,或后台 seed 收尾晚于签发),引擎撤销它:verified_at清空、blocking记上、details保留原attested再补一条revoked说明是哪个值推翻的。范围刻意收窄到本次启动亲手创建的库上的自证行 —— 扫过全库的真实迁移证据,不该被一次写入的观察推翻。记忆化(PM 要求一并审查的第二张脸)
结论:会脱节,是同一个 bug 的第二张脸,已在本 PR 一并修。
首启之所以「看起来是绿的」,一半靠的就是进程内正好缓存了
false。sys_migration在 kernel init 期间才注册,而第一条写完全可能赶在它之前 —— 那一次读根本没读到账本,却被当成结论冻结了一整个进程的姿态。于是同一份代码、同一份数据,启动姿态取决于「哪条写先跑」。现在区分两种否定:
verified_at为空 /blocking非零)—— 是结论,照旧缓存,想立刻生效仍走invalidateDataMigrationFlags();sys_migration未注册、查询抛错)—— 依旧答false(问不到的闸必须关着),但不记住,下一次写再问一次。代价是账本存在之前每次写多一次 registry 查表(在任何查询之前就短路),账本可读之后即止。
kernel:bootstrapped上那条 ADR-0104 建议行同理改为直接读账本 —— 否则一个刚刚自证成功的新部署,会被告知去跑一条它已经不需要跑的迁移。测试
回归测试按 PM 的验收标准写成**「两次启动」**,不是单次:两个 engine 跑同一个 store,第二个 engine 的 driver 报告表已存在(
wasDatastoreCreatedFromEmpty() === false)。单次启动的测试根本看不见这个缺陷 —— 坏的构建里首启也是绿的。packages/objectql/src/adr0104-attestation-evidence.test.ts(新增 8 条)覆盖:反例按 migration id 记账、两道闸互不牵连、干净启动不留反例、两次启动同一数据集第二次不得拒绝首次写入的数据、以及它的对照组(没被推翻的证书在第二次启动照常强制,说明修的是「未获证」而不是「关掉了强制」)、非本次创建的库永不撤销、记忆化重问。packages/platform-objects/src/{system/migration-flag,plugin}.test.ts(新增 8 条)覆盖:被证伪的 id 不自证且日志给出处方、只拒被证伪的那一条、订阅app:seeded、两次 pass 幂等。反向验证过:把源码 stash 掉、只留测试文件跑,8 条里 6 条失败(含「两次启动」与记忆化两条),确认它们真的钉住了这个回归。
范围
packages/spec/**零改动(按 PM 更正的硬约束;spec 侧只读引用FILE_REFERENCES_MIGRATION_ID等常量)。service-storage/plugin-auth/service-automation/plugin-approvals(并行车道)。examples/app-showcase的种子数据 —— 那在 bug(example-showcase): 启动日志暴露的 4 个 showcase 自身问题 —— 夜间 job 永不运行、权限集引用不存在的 capability、退役的 retry 字段、非法 cover 种子值 #4774 单独跟踪,且改了只会让这个部署不再触发,不构成修复。content/docs/releases/。改动落在
packages/objectql与packages/platform-objects两个包:自证的写入者与时机本来就在 platform-objects(#4243 把它从 service-storage 移过去的),不动它无法修「时机」这一半;两个包都不在本轮并行车道上。留在门外的两个窗口(已另立 issue,未在本 PR 修)
kernel:ready的兜底自证可能签在尾部数据落定之前,那批尾行会在首启即被拒(跨启动一致,不再翻转,但仍是「一次启动两套契约」)。关它需要先决定 multi-tenant /skipSeedData模式下的期望姿态。🤖 Generated with Claude Code
https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny