从 #5226 的实施中证伪原前提后定位到的真实缺陷,严重度高于原单。由 PR #5350 的 dev 实测发现,落点在 engine 车道、超出该单声明文件面,故独立立单。未认领。
先说 #5226 的前提被证伪了
#5226 断言「dev --fresh 上 sys_audit_log 表根本没建」。实测(origin/main 1792384)不成立:
AuditPlugin: system tables provisioned — sys_audit_log→telemetry, sys_activity→telemetry,
sys_comment→com.objectstack.driver.sql
| 文件 |
有 sys_audit_log? |
行数 |
dev.db(主库) |
否 |
— |
dev.telemetry.db |
是 |
50 |
sys_audit_log 的 lifecycle.class: 'audit' 被 ADR-0057 §3.6 路由到专用 telemetry 数据源,os dev 默认以兄弟文件提供一个。表建好了,审计也确实在落盘。#4887 记录过这个「在另一个 store 里」的误诊形状。
原单列出的两个待查方向双双被排除:AuditPlugin 确实通过 manifest 注册进 schema sync;dev 组合也没有缺任何 service/对象包。
真实缺陷
同一次 boot 的完整计数:sys_audit_log insert 尝试 52 次、成功 50 次、失败 2 次,且失败的两次堆栈全部带 knex 的 trxClient.query 帧,成功的没有一次带。
机制:
protocol.saveMetaItem 在主数据源上开启事务;
- 事务内写
sys_metadata 触发 afterInsert 钩子 → 审计写;
getDriver('sys_audit_log') 正确解析到 telemetry 驱动;
- 但
buildDriverOptions(packages/objectql/src/engine.ts:1609)把 txStore 里的 ambient 事务句柄无条件塞进 driver options —— 那个句柄属于主库连接;
- knex 的
.transacting(trx) 于是把语句发到主库执行,而主库没有这张表 → no such table: sys_audit_log。
即 ADR-0067 D2「加入已开启的 ambient 事务」在应用时没有校验该事务是否属于同一个 driver。origin/main 上该处注释自陈「Explicit wins; ambient is the safety net」—— 漏的正是这张网:
const tx = execCtx?.transaction !== undefined
? execCtx.transaction
: this.txStore.getStore()?.transaction; // ← 不问这个 tx 属于哪个数据源
影响面(比 #5226 描述的宽得多)
不限于元数据写。 用 POST /api/v1/batch + transaction: true 写一条普通业务记录 showcase_category,同样丢审计行:
Audit write failed {"object":"showcase_category"}
Audit write failed {"object":"sys_metadata"}
Audit write failed {"object":"sys_metadata_history"}
结论:凡是在事务中执行的被审计写入,其合规审计行都会丢失 —— 只要该部署启用了 lifecycle 数据源分流(os dev 默认开;生产上设了 OS_TELEMETRY_DB 亦然)。业务写本身成功、接口 200、数据在盘上,只有「谁做的」那一行没了,且无人重试。
受影响的是每一个 lifecycle class 为 audit / telemetry / event 的对象,不止审计。判别式是数据源分流,不是 plugin 作者身份;反证:sys_comment 没有 lifecycle class、留在主库,从不失败。
⚠️ 需要维护者拍板的语义问题(已挂 needs-user-decision)
问题:当 ambient 事务内的一次写入解析到与开启该事务的数据源不同的数据源时,引擎应当怎么做?今天它静默地把外来事务句柄穿过去、在错误的连接上执行 —— 那就是本缺陷。任何修法都必须选定语义,而这个选择改变的是 ADR-0067 对所有多数据源部署的事务契约,不只是审计。
以下四选项与推荐由 PR #5350 的 dev 给出。PM 原先在本单写过一版三选项分析,已被这一版取代 —— 它多出选项 D,并指出 C 在当前 driver 契约下不可交付,比 PM 那版准确。
- A —— 同源校验后自动提交:
buildDriverOptions 仅在解析到的 driver 就是该事务的属主时才穿入 ambient trx(需要 txStore 记录属主 driver,对引擎事务核心是一处小增补)。跨数据源写入在事务之外执行。代价:外层事务回滚时审计行仍然留下 —— 一条描述「从未发生的写入」的孤儿账目。
- B —— 同源校验后拒绝:跨数据源写入抛具名错误,强制调用方显式选择。代价:把今天的静默丢数据换成硬失败,而这些路径在单数据源部署上是好的;每一个被审计的事务写入都会中断,直到逐个调用点更新。
- C —— 按数据源开嵌套事务:在第二个 driver 上开伴随事务,一起提交/回滚。代价:需要 driver 并不具备的两阶段提交语义;提交中途失败会让两个存储互相矛盾 —— 用一个新的持久性风险替换当前这个。
- D —— 取消分流:让带 lifecycle class 的对象留在主数据源(dev 里
OS_TELEMETRY_DB 默认关)。代价:放弃 ADR-0057 §3.6 的增长隔离目标;是掩盖而非修复引擎缺陷,任何把某个对象路由到第二数据源的部署照样会被咬。
推荐:A,并把孤儿行的后果写进代码 + 修订 ADR。
项目长远合理性:A 在生产者处修复 —— 引擎的事务路由,错误假设就住在那里 —— 而不是教审计插件绕开它;消费端的绕行正是 PD#12 禁止的 ?? 回退形态,而且会把其它所有跨数据源写入继续留在坏状态。它也保住了 ADR-0057 §3.6 的增长隔离,而 D 把它丢掉。B 与 C 在原子性上更纯粹,但在当前 driver 契约下都不可交付:C 需要 driver 没有的两阶段提交,B 把静默丢失变成一批今天健康的路径集体启动即失败。对一个只追加的合规账本,多记(A 的孤儿行)是正确的失败方向 —— 一条对应已回滚写入的审计行是可对账的麻烦,而一条已提交写入却缺失的审计行是不可恢复的合规空洞,那正是今天在发的版本。
防 AI 写元数据犯错:A 是唯一让该不变量结构化且可检查的选项 —— 引擎拒绝把不属于某 driver 的事务句柄交给它,于是任何插件作者(人或 AI)都无法靠写一个普通钩子重现这个缺陷,也不需要知道数据源分流的存在才能写对。随后必须把孤儿行语义写进 ADR-0067 并按 PD#13 锚定,好让下一个读者知道自己站在哪条裁决上。
由于 A 仍然改变了一条已记录 ADR 的事务语义,需要维护者拍板 + ADR 修订。
与其它单的关系
验收建议
- 回归测试:在主库事务内写一个被审计对象,断言审计行确实落在 telemetry 数据源上(⛔ 不要断言「没有报错」—— 当前缺陷下不报错的路径也是错的)。
- 反证测试:
sys_comment(无 lifecycle class)路径行为不变。
- 覆盖普通业务对象 +
POST /api/v1/batch transaction: true,不只元数据写。
顺带(留给接手 engine 的人)
os dev 一次 boot 打印了两次 Schema sync complete {synced:94, skipped:2, total:96} —— 对 96 个对象跑了两遍完整同步(ObjectQLPlugin.start Phase 1 与 Phase 3)。非用户可见,本单不处理,记在这里只因为它正好在引擎修复要读的那条代码路径上。
从 #5226 的实施中证伪原前提后定位到的真实缺陷,严重度高于原单。由 PR #5350 的 dev 实测发现,落点在 engine 车道、超出该单声明文件面,故独立立单。未认领。
先说 #5226 的前提被证伪了
#5226 断言「
dev --fresh上sys_audit_log表根本没建」。实测(origin/main1792384)不成立:sys_audit_log?dev.db(主库)dev.telemetry.dbsys_audit_log的lifecycle.class: 'audit'被 ADR-0057 §3.6 路由到专用telemetry数据源,os dev默认以兄弟文件提供一个。表建好了,审计也确实在落盘。#4887 记录过这个「在另一个 store 里」的误诊形状。原单列出的两个待查方向双双被排除:AuditPlugin 确实通过 manifest 注册进 schema sync;dev 组合也没有缺任何 service/对象包。
真实缺陷
同一次 boot 的完整计数:
sys_audit_loginsert 尝试 52 次、成功 50 次、失败 2 次,且失败的两次堆栈全部带 knex 的trxClient.query帧,成功的没有一次带。机制:
protocol.saveMetaItem在主数据源上开启事务;sys_metadata触发afterInsert钩子 → 审计写;getDriver('sys_audit_log')正确解析到 telemetry 驱动;buildDriverOptions(packages/objectql/src/engine.ts:1609)把txStore里的 ambient 事务句柄无条件塞进 driver options —— 那个句柄属于主库连接;.transacting(trx)于是把语句发到主库执行,而主库没有这张表 →no such table: sys_audit_log。即 ADR-0067 D2「加入已开启的 ambient 事务」在应用时没有校验该事务是否属于同一个 driver。
origin/main上该处注释自陈「Explicit wins; ambient is the safety net」—— 漏的正是这张网:影响面(比 #5226 描述的宽得多)
不限于元数据写。 用
POST /api/v1/batch+transaction: true写一条普通业务记录showcase_category,同样丢审计行:结论:凡是在事务中执行的被审计写入,其合规审计行都会丢失 —— 只要该部署启用了 lifecycle 数据源分流(
os dev默认开;生产上设了OS_TELEMETRY_DB亦然)。业务写本身成功、接口 200、数据在盘上,只有「谁做的」那一行没了,且无人重试。受影响的是每一个 lifecycle class 为
audit/telemetry/event的对象,不止审计。判别式是数据源分流,不是 plugin 作者身份;反证:sys_comment没有 lifecycle class、留在主库,从不失败。needs-user-decision)问题:当 ambient 事务内的一次写入解析到与开启该事务的数据源不同的数据源时,引擎应当怎么做?今天它静默地把外来事务句柄穿过去、在错误的连接上执行 —— 那就是本缺陷。任何修法都必须选定语义,而这个选择改变的是 ADR-0067 对所有多数据源部署的事务契约,不只是审计。
buildDriverOptions仅在解析到的 driver 就是该事务的属主时才穿入 ambient trx(需要txStore记录属主 driver,对引擎事务核心是一处小增补)。跨数据源写入在事务之外执行。代价:外层事务回滚时审计行仍然留下 —— 一条描述「从未发生的写入」的孤儿账目。OS_TELEMETRY_DB默认关)。代价:放弃 ADR-0057 §3.6 的增长隔离目标;是掩盖而非修复引擎缺陷,任何把某个对象路由到第二数据源的部署照样会被咬。推荐:A,并把孤儿行的后果写进代码 + 修订 ADR。
项目长远合理性:A 在生产者处修复 —— 引擎的事务路由,错误假设就住在那里 —— 而不是教审计插件绕开它;消费端的绕行正是 PD#12 禁止的
??回退形态,而且会把其它所有跨数据源写入继续留在坏状态。它也保住了 ADR-0057 §3.6 的增长隔离,而 D 把它丢掉。B 与 C 在原子性上更纯粹,但在当前 driver 契约下都不可交付:C 需要 driver 没有的两阶段提交,B 把静默丢失变成一批今天健康的路径集体启动即失败。对一个只追加的合规账本,多记(A 的孤儿行)是正确的失败方向 —— 一条对应已回滚写入的审计行是可对账的麻烦,而一条已提交写入却缺失的审计行是不可恢复的合规空洞,那正是今天在发的版本。防 AI 写元数据犯错:A 是唯一让该不变量结构化且可检查的选项 —— 引擎拒绝把不属于某 driver 的事务句柄交给它,于是任何插件作者(人或 AI)都无法靠写一个普通钩子重现这个缺陷,也不需要知道数据源分流的存在才能写对。随后必须把孤儿行语义写进 ADR-0067 并按 PD#13 锚定,好让下一个读者知道自己站在哪条裁决上。
由于 A 仍然改变了一条已记录 ADR 的事务语义,需要维护者拍板 + ADR 修订。
与其它单的关系
objectstack dev --fresh上 sys_audit_log 表不存在 —— 每次元数据写入都以 ERROR + 「Audit write failed」告终,审计轨迹静默丢失 #5226:原单的第二个缺陷(日志级别应为error而非WARN)已由 PR fix(plugin-audit): 审计行写失败升为error并只报一次 (#5226 之二);#5226 主缺陷前提被证伪 #5350 落地(PM 已把该 PR 的Fixes改为Refs,不会误关)。原单主缺陷即本单。验收建议
sys_comment(无 lifecycle class)路径行为不变。POST /api/v1/batchtransaction: true,不只元数据写。顺带(留给接手 engine 的人)
os dev一次 boot 打印了两次Schema sync complete {synced:94, skipped:2, total:96}—— 对 96 个对象跑了两遍完整同步(ObjectQLPlugin.startPhase 1 与 Phase 3)。非用户可见,本单不处理,记在这里只因为它正好在引擎修复要读的那条代码路径上。