发现于 #5860 的实施核对(#5846 (b) 半边)。本单是 #5860 的阻塞项 ,落点在 packages/objectql(engine-core 车道),与 #5860 的文件面不相交。
背景
#5860 要求把 plugin-audit 的「哪些对象要审计」这条知识,从 handler 内部(SKIP_OBJECTS 早退)搬到注册面 上,好让 #5284 的单 id 按对象前置行门、#5038 的批量按对象门对 SKIP_OBJECTS 内的对象判假。
在 packages/plugins/plugin-audit 的文件面内做不到 ,原因不在插件,而在引擎的 hook 注册契约本身只有一种表达能力。
事实(origin/main 逐处核对)
契约面,packages/objectql/src/engine.ts:
HookEntry.object?: string | string[](645/647 行),registerHook 的 options 同形(1096 行);
triggerHooks 的按对象匹配(1363 行):targets.includes('*') || targets.includes(context.object);
hasHooksFor(event, object)(1396 行)镜像同一条,并且 if (!entry.object) return true;。
即:注册面只能表达允许列表 (外加 '*' 全集),没有任何否定 / 排除 / 谓词的表达位。
而 plugin-audit 的知识是一条拒绝列表 (packages/plugins/plugin-audit/src/audit-writers.ts:96 的 SKIP_OBJECTS,约 20 张 sys_* 平台表)。允许列表与拒绝列表只在封闭全集 上可互换,而对象全集在运行期是开放的 :
packages/metadata-protocol/src/protocol.ts:7216 applyObjectRegistryMutation 在 /meta PUT 成功后直接 engine.registry.registerObject(...),把新授权的对象注册进引擎;
SchemaRegistry.registerObject(packages/objectql/src/registry.ts:1036)不发任何事件 (该文件 emit( 出现 0 次),这条路径也不宣告 metadata:reloaded(只有 metadata 插件的 artifact reload 和 packages 域的 publish 会宣告)。
所以插件侧没有任何可订阅的通道,能把一份枚举出来的允许列表保持为最新。
实测(临时探针,测完即删;计数驱动上的 driver.findOne 增量)
场景
findOne 增量
说明
A 今天:audit 形状的全局 afterUpdate + 对 SKIP_OBJECTS 内对象做单 id update
1
#5860 的红态,前提成立
B 同一注册,对普通业务对象 update
1
对照
C 允许列表注册 { object: ['biz_task'] }:对 SKIP_OBJECTS 对象 / 对被枚举对象
0 / 1
门确实会翻 —— 只要表达得出来
D 允许列表注册之后再注册 一个新对象,对它 update
0,且审计 handler 一次都没跑
枚举补集的真实代价
D 是决定性的一条:选项 1(枚举 SKIP_OBJECTS 的补集)把「新对象默认被审计」变成「新对象静默不被审计」—— 合规方向的行为倒退,而且无声。
需要的契约形状
在 registerHook 的 options 与 HookEntry 上新增一个声明式的对象排除面 ,由 triggerHooks 与 hasHooksFor 共同读取:
// packages/objectql/src/engine.ts
export interface HookEntry {
object ?: string | string [ ] ; // 现有:允许列表(缺省 = 全局)
excludeObjects ?: string | string [ ] ; // 新增:从上面的集合里减掉这些名字
// ...
}
匹配语义:matches(entry, X) = allowMatches(entry, X) && !excludeMatches(entry, X)。两个消费者(派发用的 triggerHooks、需求门用的 hasHooksFor)必须读同一个 匹配函数 —— 这两处今天已经是「一份语义两份实现」,再加一维会把 #5038 注释里那条「门比派发更紧就会静默丢 hook」的风险放大。
为什么建议这个形状,而不是谓词回调(objectFilter?: (object: string) => boolean):
真实业务需求 :今天就有两个调用方 —— plugin-audit 的 5 个注册(plugin-audit 的 5 个 hook 全部无 object 注册 ⇒ 引擎「按对象」需求门(#5284 单 id / #5038 批量)在 audit 启用时恒真 #5860 ),以及 update() 的前置行门是全局的(hooks.get('afterUpdate').length > 0),任一对象注册 afterUpdate 就让所有对象的单 id update 多付一次读 #5284 注释点名的另一个全局注册方 service-storage 的 file-reference reconcile。两者都是「全局,除了这几张平台表」,都是静态名单;谓词回调多出来的表达力当前没有任何调用方需要。
本项目的长期正确性 :excludeObjects 是本仓已有的词汇(packages/spec/src/api/rest-server.zod.ts:374、packages/spec/src/system/disaster-recovery.zod.ts:222 都用它表达「全部对象减去这些」),不新造名词;并且它保持「声明 = 执行」—— 排除名单是引擎读的数据,而不是藏在 handler 里的早退。
让 AI 写的元数据/代码难以出错 :静态名单可打印、可在 logger.debug('Registered hook', ...) 里如实报出、可被诊断面枚举;谓词回调把任意插件代码塞进每次写入的热路径,且无法内省 —— 还会诱导插件从可变状态计算作用域,正好撞上 AGENTS.md「启动期注册表读数不得记录裁决」那一节。
影响面
Refs:#5860 、#5846 、#5284 、#5038 、#5272
发现于 #5860 的实施核对(#5846 (b) 半边)。本单是 #5860 的阻塞项,落点在
packages/objectql(engine-core 车道),与 #5860 的文件面不相交。背景
#5860 要求把 plugin-audit 的「哪些对象要审计」这条知识,从 handler 内部(
SKIP_OBJECTS早退)搬到注册面上,好让 #5284 的单 id 按对象前置行门、#5038 的批量按对象门对SKIP_OBJECTS内的对象判假。在
packages/plugins/plugin-audit的文件面内做不到,原因不在插件,而在引擎的 hook 注册契约本身只有一种表达能力。事实(origin/main 逐处核对)
契约面,
packages/objectql/src/engine.ts:HookEntry.object?: string | string[](645/647 行),registerHook的 options 同形(1096 行);triggerHooks的按对象匹配(1363 行):targets.includes('*') || targets.includes(context.object);hasHooksFor(event, object)(1396 行)镜像同一条,并且if (!entry.object) return true;。即:注册面只能表达允许列表(外加
'*'全集),没有任何否定 / 排除 / 谓词的表达位。而 plugin-audit 的知识是一条拒绝列表(
packages/plugins/plugin-audit/src/audit-writers.ts:96的SKIP_OBJECTS,约 20 张sys_*平台表)。允许列表与拒绝列表只在封闭全集上可互换,而对象全集在运行期是开放的:packages/metadata-protocol/src/protocol.ts:7216applyObjectRegistryMutation在/metaPUT 成功后直接engine.registry.registerObject(...),把新授权的对象注册进引擎;SchemaRegistry.registerObject(packages/objectql/src/registry.ts:1036)不发任何事件(该文件emit(出现 0 次),这条路径也不宣告metadata:reloaded(只有 metadata 插件的 artifact reload 和 packages 域的 publish 会宣告)。所以插件侧没有任何可订阅的通道,能把一份枚举出来的允许列表保持为最新。
实测(临时探针,测完即删;计数驱动上的
driver.findOne增量)afterUpdate+ 对SKIP_OBJECTS内对象做单 id update{ object: ['biz_task'] }:对SKIP_OBJECTS对象 / 对被枚举对象D 是决定性的一条:选项 1(枚举
SKIP_OBJECTS的补集)把「新对象默认被审计」变成「新对象静默不被审计」—— 合规方向的行为倒退,而且无声。需要的契约形状
在
registerHook的 options 与HookEntry上新增一个声明式的对象排除面,由triggerHooks与hasHooksFor共同读取:匹配语义:
matches(entry, X) = allowMatches(entry, X) && !excludeMatches(entry, X)。两个消费者(派发用的triggerHooks、需求门用的hasHooksFor)必须读同一个匹配函数 —— 这两处今天已经是「一份语义两份实现」,再加一维会把 #5038 注释里那条「门比派发更紧就会静默丢 hook」的风险放大。为什么建议这个形状,而不是谓词回调(
objectFilter?: (object: string) => boolean):object注册 ⇒ 引擎「按对象」需求门(#5284 单 id / #5038 批量)在 audit 启用时恒真 #5860),以及update()的前置行门是全局的(hooks.get('afterUpdate').length > 0),任一对象注册 afterUpdate 就让所有对象的单 id update 多付一次读 #5284 注释点名的另一个全局注册方 service-storage 的 file-reference reconcile。两者都是「全局,除了这几张平台表」,都是静态名单;谓词回调多出来的表达力当前没有任何调用方需要。excludeObjects是本仓已有的词汇(packages/spec/src/api/rest-server.zod.ts:374、packages/spec/src/system/disaster-recovery.zod.ts:222都用它表达「全部对象减去这些」),不新造名词;并且它保持「声明 = 执行」—— 排除名单是引擎读的数据,而不是藏在 handler 里的早退。logger.debug('Registered hook', ...)里如实报出、可被诊断面枚举;谓词回调把任意插件代码塞进每次写入的热路径,且无法内省 —— 还会诱导插件从可变状态计算作用域,正好撞上 AGENTS.md「启动期注册表读数不得记录裁决」那一节。影响面
object注册 ⇒ 引擎「按对象」需求门(#5284 单 id / #5038 批量)在 audit 启用时恒真 #5860 被本单阻塞:没有这个表达位,plugin-audit 侧只能在「门判假」和「新对象默认被审计」之间二选一,而后者按分诊明令不可牺牲。object注册 ⇒ 引擎「按对象」需求门(#5284 单 id / #5038 批量)在 audit 启用时恒真 #5860 的改动就收敛成:5 个注册带上excludeObjects,SKIP_OBJECTS从 handler 早退升格为注册面声明(handler 早退建议保留为纵深防御,行为守恒)。Refs:#5860、#5846、#5284、#5038、#5272