Repository navigation
hook 注册契约只能表达「命中这些对象」,无法表达「全局但排除这些对象」—— #5860 因此在 plugin-audit 内无法落地 #5928
Description
发现于 #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 增量)
| 场景 | 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「启动期注册表读数不得记录裁决」那一节。
影响面
- plugin-audit 的 5 个 hook 全部无
object注册 ⇒ 引擎「按对象」需求门(#5284 单 id / #5038 批量)在 audit 启用时恒真 #5860 被本单阻塞:没有这个表达位,plugin-audit 侧只能在「门判假」和「新对象默认被审计」之间二选一,而后者按分诊明令不可牺牲。 - 本单落地后,plugin-audit 的 5 个 hook 全部无
object注册 ⇒ 引擎「按对象」需求门(#5284 单 id / #5038 批量)在 audit 启用时恒真 #5860 的改动就收敛成:5 个注册带上excludeObjects,SKIP_OBJECTS从 handler 早退升格为注册面声明(handler 早退建议保留为纵深防御,行为守恒)。 - delete 侧另有一层与本单独立的原因让门恒真,单独记录。
Activity
claude commentedon Aug 6, 2026
裁决(2026-08-06,维护者批准):采纳 A 形状——
excludeObjects声明式排除面。registerHookoptions 与HookEntry新增excludeObjects?: string | string[],匹配语义matches(entry, X) = allowMatches(entry, X) && !excludeMatches(entry, X)。本单前提已对 origin/main(2381c19,立单后 main 已动)重验,全部成立;探针 D(枚举补集 ⇒ 运行期新注册对象静默不被审计)是决定性反证,方案「枚举补集」与「谓词回调」均排除。两处论据修正(以本评论为准,别让原表述进 ADR/changeset 叙事):
{ packageId }),但其过滤是动态谓词(activeFileFields(engine, object)早退)——excludeObjects表达不了它,它也不该用静态名单表达(同一个探针 D 陷阱:新对象带 file 字段会静默不被 claim)。业务拉动仍然足够:合规关键插件被阻塞(plugin-audit 的 5 个 hook 全部无object注册 ⇒ 引擎「按对象」需求门(#5284 单 id / #5038 批量)在 audit 启用时恒真 #5860),update()的前置行门是全局的(hooks.get('afterUpdate').length > 0),任一对象注册 afterUpdate 就让所有对象的单 id update 多付一次读 #5284/[17.x] 批量写按行语义实现:hook 按行触发 + record-change trigger 按行绑定 previous/record(#4800/#4862 拍板 A) #5038 两个已落地的门在 audit 启用时空转。packages/objectql:registerHook的契约面声明在packages/spec/src/contracts/objectql-engine.ts:141——domain:spec标签是对的,完整文件面 = spec contracts 一小块 + objectql 实现(匹配函数、triggerHooks/hasHooksFor两个消费点、测试)。实施红线(派发令必带):
triggerHooks(engine.ts:1363)与hasHooksFor(:1396)今天是同一语义的两份手写实现——本次收敛为一个共享matches(),并加性质 pin:门永远不得比派发更紧([17.x] 批量写按行语义实现:hook 按行触发 + record-change trigger 按行绑定 previous/record(#4800/#4862 拍板 A) #5038 注释点名的静默丢 hook 风险,这次焊死)。hook-empty-target-not-wildcard,未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001 系):excludeObjects里的''/['']响亮拒绝;'*'出现在排除面 = 永不触发的 hook(ADR-0078 never-fire),同样拒绝。objectFilter谓词回调(零消费者、热路径不可内省);不动packages/spec/src/data/hook.zod.ts的 authorable HookSchema——零元数据作者需要「全局减名单」,给零作者扩 authorable surface 违反 主题引擎发出的 9 组 CSS 变量零消费方(--font-size-*/--z-*/--duration-*…):ADR-0049 该判去留 #5021/ui/ 五个交互配置文件(22 个 z.object 站点)没有任何承载键:实测「无授权门」,按 ADR-0049 定去留 #4988 判例;等真实拉动再镜像。excludeObjects而引擎忽略它」的 declared≠enforced 窗口(datasourcepool声明在 sqlite / sqlite-wasm 驱动臂被静默丢弃(pg / mysql 生效) #5714 同族)。由分诊指定单一车道(建议 spec 座位——契约面归它)认领,认领评论申报完整文件面(spec contracts + objectql),跑定向在飞检查。注册日志(logger.debug('Registered hook', …))如实带上排除面。changeset:spec + objectql 双包 minor(参照hook-empty-target-not-wildcard的形状)。排程注意:
engine.ts是当前热区(#5850/#5754 刚合;#5748/#5574/#5846(a)/#5929 已在队列)——入队后与这些串行,派发前重查在飞。落地连锁:合入 ⇒ #5860 解锁,收敛为「5 个注册带
excludeObjects: [...SKIP_OBJECTS]+writeCommentMentions顺带收为{ object: 'sys_comment' }+ update 侧先红后绿验收」(identity 车道已备好重派);#5929 独立,不受影响。经办:PM 会话
session_01GcjbQLUQKysMU9uXB34iyv;维护者 2026-08-06 审阅评估报告后批准。Generated by Claude Code
hotlong commentedon Aug 7, 2026
Release-board audit (maintainer-directed re-audit of non-board
domain:specitems, 2026-08-07): addingtarget:v17— metadata-protocol change, size M, low-risk: maintainer-approved (2026-08-06) additiveexcludeObjectson HookEntry/registerHook, spec contracts + objectql in one PR with a singlematches(); authorable HookSchema untouched, no stored data. Maintainer directive: protocol changes land in v17 unless large/risky. Triage seat may veto.Generated by Claude Code
fix(objectql)!: `registerHook` refuses an empty `object` target and a…
docs(plugin-audit): re-anchor the dead tracker citations to the commi…
docs(service-job): re-anchor the dead tracker citation to the commit …
test(spec): contracts and conversions test titles state each cited de…