Repository navigation
Description
TL;DR
第一次 pnpm dev 全绿(130 rows,0 ERROR)。从第二次启动起,永久变成 10 条 ERROR、10 条种子记录写不进去 —— 数据没变、代码没变,只是重启了一次。
被拒的正是首启自己写进去的数据。
复现
rm -rf examples/app-showcase/.objectstack
pnpm dev # 启动完成后 Ctrl+C
pnpm dev # 第二次| ERROR 行数 | Seeds | |
|---|---|---|
冷启(删掉 .objectstack) |
0 | com.example.showcase 130 rows |
| 第二次启动 | 10 | ⚠ 120 ok / 10 errors ⚠ |
第二次启动的报错(×10,每条带完整栈):
ERROR Update operation failed {"object":"showcase_task","error":{"message":
"Cover Image has an invalid image value: Expected an opaque sys_file id",
"stack":"ValidationError: ... at validateRecord (packages/objectql/dist/index.mjs:2208:32)"}}
WARN [Seeder] Seed loading completed with 10 dropped record(s) and 10 error(s)
{"inserted":0,"updated":1,"skipped":119,"errored":10}
冷启时同一份数据只产生一条 warn-first:
[value-shape] cover has an invalid image value: Expected an opaque sys_file id
— accepted for now (ADR-0104 warn-first; run `os migrate files-to-references --apply` ...)
根因
冷启过程中,平台往 sys_migration 写了两行(直接查 examples/app-showcase/.objectstack/data/dev.db 得到):
{"id":"adr-0104-file-references","last_run_at":"2026-08-03T04:24:43.382Z",
"verified_at":"2026-08-03T04:24:43.382Z","blocking":0,
"details":"{\"attested\":\"datastore-created-empty\"}"}
{"id":"adr-0104-value-shapes", ...同上}意图是合理的:全新空库没有历史值,天然满足迁移,所以标成 verified,让新部署直接进 strict("born-migrated")。
问题是顺序反了。 这个自证写在 04:24:43.382,而同一次启动的 seed 在 04:24:44.3+ 才跑,写进去的 cover 值恰恰违反刚刚被证明的契约。也就是说:部署先给自己开了"我的数据符合 ADR-0104"的证明,然后立刻写入不符合的数据。
首启为什么还是绿的 —— ObjectQL.isFileReferencesMigrationVerified() 把 flag 读结果记忆化:
async isFileReferencesMigrationVerified(): Promise<boolean> {
if (!this.fileReferencesMigrationVerified) {
this.fileReferencesMigrationVerified = this.readMigrationFlagVerified(...);
}
return this.fileReferencesMigrationVerified;
}首启在 flag 行写入之前就已经有写操作触发过这次读取,于是整个进程缓存了 false,一路保持 warn-first。第二次启动是新进程,第一次读就看到已存在的 verified 行 → 缓存 true → mediaStrictEffective() 返回 true → record-validator.ts:519 走 fail('invalid_type', ...) 而不是 warnOnce(...)。
相关代码:
packages/objectql/src/validation/record-validator.ts:513-533— strict 时fail,否则warnOncepackages/objectql/src/engine.ts:3061-3072— 记忆化的 flag 读取packages/spec/src/system/migration.zod.ts:138—FILE_REFERENCES_MIGRATION_ID
要恢复的不变量
一次启动不能证明一个它即将在同一次启动里违反的契约。
修法(需要定夺,故未直接动手)
- 把 attestation 推迟到首启 seed 之后,并对 seed 完的数据重跑自检 —— 空库的前提在 seed 写入后就不再成立了,"empty ⇒ clean" 这个推理需要在真正没有数据可写之后才下结论。
- 让 seed 走同一套契约 —— 让 insert 和后续 update 口径一致:要么都接受、要么都拒绝。目前 insert 宽松、update 严格,本身就是个不对称。
倾向 1,因为它修的是"证据在被推翻之前就被记账"这个根本问题;2 只是让两条路径一样糟或一样严。
顺带:adr-0104-value-shapes 那行是同一时刻、同样 attested: datastore-created-empty 写下的,同一个问题,别只修 media 那半边。
关联
showcase 的 cover 种子值(src/data/seed/index.ts 的 placeholderCover(...))确实不是 opaque sys_file id —— 那是本 issue 的诱因数据,单独在 showcase 的 issue 里跟踪;但把种子数据改干净只是让这个部署不再触发,不修复"提前自证"本身。
Activity
os-zhuang commentedon Aug 3, 2026
认领:PM 循环第 3 轮
会话:
session_015Br2xsJsczFsTR9bvbh2Ny分支:
claude/issue-4769-adr0104-attest-after-seedWorktree:
objectstack-issue-4769分诊:落地 objectstack(后端);PM 裁定取修法 1(推迟 attestation 到首启 seed 之后)。
理由按两条轴:
attested: datastore-created-empty这个推理的前提是「没有数据可写」,而它却写在同一次启动即将写入 130 行之前 —— 前提在落笔那一刻就已经不成立了。修法 1 修的是这个。范围要求(两条一起,别只修 media 那半边):
adr-0104-file-references与adr-0104-value-shapes两行是同一时刻、同样attested写下的,同一个缺陷。另外请一并确认
engine.ts:3061-3072那个记忆化本身:首启之所以「看起来是绿的」,靠的是进程内缓存了false这个巧合。attestation 时序修正后,这个记忆化是否还会让同一进程内的读取结果与库里的实际状态脱节 —— 如果会,那是同一个 bug 的第二张脸。examples/app-showcase的种子值问题单独在 #4774 跟踪,本 issue 不要去改 showcase 数据 —— 那只会让这个部署不再触发,不构成修复。维护者可否决本裁定;不阻塞派发。
Generated by Claude Code
os-zhuang commentedon Aug 3, 2026
交叉链接:本 issue 与 #4771、#4772 是同一个形状在三个互不相干子系统里的三次独立发作。已立决策单 #4776。
本 issue 在那份分析里是分量最重的一例 —— 另两处的后果止于日志误报,而这里那条推理链的终点是往数据库写了一行永久的、错误的合规证明:一次启动给自己开了一张它随即违反的证明,下一次启动拿这张证明去拒绝数据。日志噪音可以忍,持久层里的假证据不能。这是我认为这一类值得考虑结构性防线(而不只是加 lint)的主要理由。
修复照常进行,不受 #4776 阻塞。
Generated by Claude Code
os-zhuang commentedon Aug 3, 2026
PM 复核:ACCEPT(待 CI 全绿) —— PR #4794。
裁定被完整执行,而且执行得比裁定更细
取修法 1 后,dev 把「一次启动不能证明它在同一次启动里违反的契约」这条不变量落在了三个位置,而不是只挪一下时机:
kernel:ready改订阅app:seeded(inline seed 的结算点),不 seed 的 kernel 仍由kernel:ready兜底,两条路进同一个幂等调用。两行(
adr-0104-file-references/adr-0104-value-shapes)各自独立判定,我要求的「别只修 media 那半边」做到了,而且做得更准——一个cover不合形状不牵连location那道闸,因为两条 migration 声称的是两件不同的事。我要求一并审查的记忆化:确认是同一 bug 的第二张脸,已一并修
结论值得记下来:首启之所以「看起来是绿的」,一半靠的是进程内正好缓存了
false。sys_migration在 kernel init 期间才注册,而第一条写完全可能赶在它之前 —— 那一次读根本没读到账本,却被当成结论冻结了整个进程的姿态。也就是说同一份代码、同一份数据,启动姿态取决于「哪条写先跑」。现在区分两种否定:「问过了,账本说不」(结论,照旧缓存)与**「根本问不到」(仍答
false,因为问不到的闸必须关着,但不冻结**,下次写再问)。这个区分是这次修复里最值得复用的一条 —— 「拿不到答案」和「答案是否」不是一回事,而把前者当后者缓存下来,正是 #4776 那一族缺陷的核心形状。测试
按验收标准写成了**「两次启动」**而非单次:两个 engine 跑同一个 store,第二个的 driver 报告表已存在。这一点是必须的 —— 单次启动的测试根本看不见这个缺陷,坏的构建里首启也是绿的。
反向验证:stash 掉两个包的
src、只留新测试跑 →Tests 6 failed | 2 passed (8),失败项含「两次启动第二次不得拒绝首次写入的数据」与「账本存在后重问,而不是冻结注册前的答案」。另有对照组证明修的是「未获证」而不是「关掉了强制」——没被推翻的证书在第二次启动照常强制。范围
packages/spec零改动(我派发后追加的硬约束,守住了);未碰并行车道的四个包;未改 showcase 种子数据(#4774 单独跟踪);未改content/docs/releases/。关于 PR 描述里的编号
我复核时发现描述引用了 #4791 / #4792 两个错误编号并去信要求修正 —— dev 在我消息送达前已自行修正为 #4795 / #4797,并说明了原因:PR 于 06:58:27 创建时那两个 issue 还没落库(06:59:29 才创建),body 里写的是占位猜测。它自己记下的教训是对的:先立 issue 再写 body。我按 06:40 之后核对没找到第二条,是早了半分钟。
两条超范围 issue 已核实存在并分诊,均标
needs-user-decision(它们各自带一个真正的产品/架构裁定点,不是 PM 能替维护者定的):skipSeedData部署的期望姿态,dev 没有猜,对。file-references那道闸兼管字段文件回收 —— 删字节是不可逆的,而另外两个最坏只是记录写不进去。Generated by Claude Code
fix(objectql,platform-objects): attest a fresh datastore on this boot…
feat(devx): gate the "startup registry verdict" class — read the regi…
fix(example-showcase): 让 4 条「声明了但运行时从不兑现」的接线真正生效 (objectstack-ai#4774) (
fix(objectql): a lax-admitted value withdraws the irreversible half o…