观察类发现(finding),来自 #5941(break-glass delete 守卫)的实测。今天没有用户会撞上:代码行为正确且被两个守卫依赖,问题在于同一段散文对它的描述不精确,而这段散文就是下一个作者能读到的唯一规格。
两条陈述
packages/spec/src/data/hook.zod.ts 的 HookContextSchema.input 契约表(#5273 / PR #5668 落地,#5964 刚把枚举注释对齐到它):
* - delete (bulk, multi:true) — before: { id: undefined, options: DriverOptions }
* - update (bulk, multi:true) — before: { id: undefined, data: Record, options: DriverOptions }
...
* The row-scoping predicate is NOT reachable from `input` at all. …
* scope the batch through `options.where` at the CALLER, or work per row on
* the `after*` events below.
实测(对 origin/main = ffd51fd7a)
packages/objectql/src/engine.ts:
| 行 |
事实 |
| 5516 → 5517 |
await this.triggerHooks('beforeUpdate', …) 之后才 hookContext.input.options = this.buildDriverOptions(…) |
| 6137 → 6152 |
await this.triggerHooks('beforeDelete', …) 之后才做同一件事 |
也就是说 before* 期间 input.options 仍是调用方那只 engine options 包(EngineUpdateOptions / EngineDeleteOptions),where 与 multi 都在;它变成 DriverOptions 是在钩子返回之后、驱动调用之前。真engine + better-sqlite3 上探针实测到的 beforeDelete 载荷:
by-id : { inputId: 'u1', inputOptionsWhere: { id: 'u1' }, previous: [...] }
multi : { inputId: undefined, inputOptionsWhere: { id: { $in: ['u2','u3'] } }, inputOptionsMulti: true }
所以契约表里两处 before 行的 options: DriverOptions 与实测不符,而「NOT reachable from input at all」+「scope … at the CALLER」这组措辞,读起来像是「钩子看不到谓词」——钉子测试并没有钉这一句:hook-input-shape-contract.test.ts 断言的是 'ast' in input === false(以及读路径的阳性对照),没有任何一条断言 input.options 上没有 where。
为什么值得记
这不是措辞洁癖:packages/plugins/plugin-auth 的两个 break-glass 守卫(#5892 的 ban 半边、#5941 的 delete 半边)正是靠 before* 期间的 input.options.where 解析谓词/multi 写的目标行集 —— 没有它,批量写这条能一次扫掉全部管理员的路径就是盲区。按现在的散文,下一个安全钩子作者会得到「谓词拿不到,放弃」的结论,或者反过来读到守卫的代码后认为它违反契约。
两句都可以同时为真,只是要把区别写明:钩子拿不到的是 composed ast(生效谓词,filters 中间件可能往上叠 RLS / sharing 的收窄);拿得到的是调用方原始 options.where。因为中间件只会收窄不会放宽,把调用方谓词当作行集是上界近似 —— 对 fail-closed 的守卫恰好是安全方向。
建议
契约表两处 before 行改成实测形状(调用方 engine options,DriverOptions 是 after* / 驱动调用起才成立),并在那段说明里补一句区分 composed ast 与调用方 options.where;顺手给 hook-input-shape-contract.test.ts 加一条正向断言(before* 的 input.options.where 就是调用方传入的谓词),这样这条被两个安全守卫依赖的性质从散文变成钉子。
参考
观察类发现(
finding),来自 #5941(break-glass delete 守卫)的实测。今天没有用户会撞上:代码行为正确且被两个守卫依赖,问题在于同一段散文对它的描述不精确,而这段散文就是下一个作者能读到的唯一规格。两条陈述
packages/spec/src/data/hook.zod.ts的HookContextSchema.input契约表(#5273 / PR #5668 落地,#5964 刚把枚举注释对齐到它):实测(对
origin/main=ffd51fd7a)packages/objectql/src/engine.ts:await this.triggerHooks('beforeUpdate', …)之后才hookContext.input.options = this.buildDriverOptions(…)await this.triggerHooks('beforeDelete', …)之后才做同一件事也就是说
before*期间input.options仍是调用方那只 engine options 包(EngineUpdateOptions/EngineDeleteOptions),where与multi都在;它变成DriverOptions是在钩子返回之后、驱动调用之前。真engine + better-sqlite3 上探针实测到的beforeDelete载荷:所以契约表里两处
before行的options: DriverOptions与实测不符,而「NOT reachable frominputat all」+「scope … at the CALLER」这组措辞,读起来像是「钩子看不到谓词」——钉子测试并没有钉这一句:hook-input-shape-contract.test.ts断言的是'ast' in input === false(以及读路径的阳性对照),没有任何一条断言input.options上没有where。为什么值得记
这不是措辞洁癖:
packages/plugins/plugin-auth的两个 break-glass 守卫(#5892 的 ban 半边、#5941 的 delete 半边)正是靠before*期间的input.options.where解析谓词/multi 写的目标行集 —— 没有它,批量写这条能一次扫掉全部管理员的路径就是盲区。按现在的散文,下一个安全钩子作者会得到「谓词拿不到,放弃」的结论,或者反过来读到守卫的代码后认为它违反契约。两句都可以同时为真,只是要把区别写明:钩子拿不到的是 composed
ast(生效谓词,filters 中间件可能往上叠 RLS / sharing 的收窄);拿得到的是调用方原始options.where。因为中间件只会收窄不会放宽,把调用方谓词当作行集是上界近似 —— 对 fail-closed 的守卫恰好是安全方向。建议
契约表两处
before行改成实测形状(调用方 engine options,DriverOptions是after*/ 驱动调用起才成立),并在那段说明里补一句区分 composedast与调用方options.where;顺手给hook-input-shape-contract.test.ts加一条正向断言(before*的input.options.where就是调用方传入的谓词),这样这条被两个安全守卫依赖的性质从散文变成钉子。参考
HookContext.input的契约注释声明批量写携带input.ast,引擎从不设置它(AST 只在 opCtx 上);同一张表也未描述 #5038 后 after 事件的按行形状 #5273 / PR docs(spec): HookContext.input 契约表改成引擎真正构造的形状 (#5273) #5668(契约表)、HookEvent的枚举注释仍在说批量写把行级谓词「carried ininput」—— 与 #5273 同一句假话,在同文件 40 行之外 #5899 / PR docs(spec): HookEvent 枚举注释不再声称批量写把行级谓词放在input(#5899) #5964(枚举注释对齐)opCtx.ast的原因)、单记录 delete 从不绑定hookContext.previous—— 契约声明「for update/delete」,引擎只在 update 分支赋值;#5038 之后批量 delete 反而比单记录 delete 更完整 #5272(delete 预取)、[17.x] 批量写按行语义实现:hook 按行触发 + record-change trigger 按行绑定 previous/record(#4800/#4862 拍板 A) #5038(bulkafter*逐行派发)