Blocked-by: #6656 (同一个裁决决定两边的实现;#6656 的 A/A+ 方案本身就修掉 update 侧的一半)
由 #6656 的开发座位实测发现,属 Prime Directive #10 的范围外发现,单独开单不夹带。
事实(origin/main @ 68feaadd6 实测)
packages/plugins/plugin-audit/src/audit-writers.ts 的 writeAudit 里,diff 的两侧来自两条不同的管线 :
before = ctx.__previous,由 captureBefore 的 ql.findOne 产出 —— 走引擎读路径 ;
after = ctx.result —— 是裸写结果 (driver.update 的返回)。
引擎读路径比裸写结果多做三件事,而且都只在 engine.find/findOne 里做(packages/objectql/src/engine.ts:5916、:6047):
maskSecretFields —— secret 字段被换成 ••••••••;
applyFormulaPlan —— formula 虚拟字段被求值;
resolveFileReferences(ADR-0104 D3)—— 文件字段的 id 引用被解析成对象。
于是每一个属于这三类的字段,在 diff 里恒不相等 ,无论用户有没有改它。
实测复现
真实 ObjectQL + 计数驱动,对象含 boolean / secret / formula 字段,单条 update() 的 payload 只有 { status: 'in_progress' }:
old_value = {"status":"todo","is_done":0,"api_key":"••••••••"}
new_value = {"status":"in_progress","is_done":false,"api_key":"secret:..."}
is_done 与 api_key 都没有被写入,却都进了审计账本。
两个后果
噪音,且用户可见。 objectui 记录 History 页会在每次编辑后多出一条"API Key 变更"/"附件变更"。这与 COMPUTED_FIELD_TYPES 上方注释记录的"原始症状"是同一族缺陷 —— 那次是 formula 字段(before 有值、after 缺键),靠按字段 类型 排除计算字段挡住了;secret 与文件引用没有对应的排除,因为它们不是派生字段,问题出在视图 而不是字段类型 。
secret ref 落进 sys_audit_log。 new_value 侧是裸写结果,所以存的是 secret 的存储引用 (secret:...),而不是掩码。这不只发生在 update:action === 'create' 时 new_value = {...ctx.result},同样是未掩码的;谓词 delete 的 old_value 走 ctx.previous(同样是裸预映像),也是未掩码的。也就是说账本里今天已经普遍存在 secret ref,只有单条 update / 单条 delete 的 old_value 是掩码 —— 这块不一致正是 plugin-audit captureBefore still fetches its own pre-image — retire the second read once the engine binds ctx.previous before every before* dispatch #6656 的裁决要处理的。
sys_secret 的明文需要特权的 engine.resolveSecret 才能取到,所以这是"引用泄漏"而非"明文泄漏";但审计账本与业务表的 ACL 不同,值不值得存 ref 是个需要明确的契约。
建议方向(与 #6656 同一个裁决)
让 diff 两侧同源。最自然的是 #6656 的 A+:before 改用 ctx.previous(与 after 同为裸视图),并在 audit writer 里对 masked-read 字段两侧都掩码 。这样幻影行消失、ref 不再入账本,同时把 #6656 想退役的那次冗余读一并退掉。
不建议的方向:在 diff() 里按字段类型再排除一批(secret / file)—— 那只是把症状按类型逐个补,视图不同源这个根因还在,下一个新增的读路径后处理会再来一次。
范围
packages/plugins/plugin-audit。不涉及 packages/objectql。
Generated by Claude Code
Blocked-by: #6656 (同一个裁决决定两边的实现;#6656 的 A/A+ 方案本身就修掉 update 侧的一半)
由 #6656 的开发座位实测发现,属 Prime Directive #10 的范围外发现,单独开单不夹带。
事实(
origin/main@68feaadd6实测)packages/plugins/plugin-audit/src/audit-writers.ts的writeAudit里,diff 的两侧来自两条不同的管线:before=ctx.__previous,由captureBefore的ql.findOne产出 —— 走引擎读路径;after=ctx.result—— 是裸写结果(driver.update的返回)。引擎读路径比裸写结果多做三件事,而且都只在
engine.find/findOne里做(packages/objectql/src/engine.ts:5916、:6047):maskSecretFields—— secret 字段被换成••••••••;applyFormulaPlan—— formula 虚拟字段被求值;resolveFileReferences(ADR-0104 D3)—— 文件字段的 id 引用被解析成对象。于是每一个属于这三类的字段,在 diff 里恒不相等,无论用户有没有改它。
实测复现
真实
ObjectQL+ 计数驱动,对象含boolean/secret/formula字段,单条update()的 payload 只有{ status: 'in_progress' }:is_done与api_key都没有被写入,却都进了审计账本。两个后果
COMPUTED_FIELD_TYPES上方注释记录的"原始症状"是同一族缺陷 —— 那次是 formula 字段(before有值、after缺键),靠按字段 类型 排除计算字段挡住了;secret 与文件引用没有对应的排除,因为它们不是派生字段,问题出在视图而不是字段类型。sys_audit_log。new_value侧是裸写结果,所以存的是 secret 的存储引用(secret:...),而不是掩码。这不只发生在 update:action === 'create'时new_value = {...ctx.result},同样是未掩码的;谓词 delete 的old_value走ctx.previous(同样是裸预映像),也是未掩码的。也就是说账本里今天已经普遍存在 secret ref,只有单条 update / 单条 delete 的old_value是掩码 —— 这块不一致正是 plugin-audit captureBefore still fetches its own pre-image — retire the second read once the engine binds ctx.previous before every before* dispatch #6656 的裁决要处理的。sys_secret的明文需要特权的engine.resolveSecret才能取到,所以这是"引用泄漏"而非"明文泄漏";但审计账本与业务表的 ACL 不同,值不值得存 ref 是个需要明确的契约。建议方向(与 #6656 同一个裁决)
让 diff 两侧同源。最自然的是 #6656 的 A+:
before改用ctx.previous(与after同为裸视图),并在 audit writer 里对 masked-read 字段两侧都掩码。这样幻影行消失、ref 不再入账本,同时把 #6656 想退役的那次冗余读一并退掉。不建议的方向:在
diff()里按字段类型再排除一批(secret / file)—— 那只是把症状按类型逐个补,视图不同源这个根因还在,下一个新增的读路径后处理会再来一次。范围
packages/plugins/plugin-audit。不涉及packages/objectql。Generated by Claude Code