Skip to content

hook 注册契约只能表达「命中这些对象」,无法表达「全局但排除这些对象」—— #5860 因此在 plugin-audit 内无法落地 #5928

Closed

Description

@baozhoutao

发现于 #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):

  1. 真实业务需求:今天就有两个调用方 —— 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。两者都是「全局,除了这几张平台表」,都是静态名单;谓词回调多出来的表达力当前没有任何调用方需要。
  2. 本项目的长期正确性:excludeObjects 是本仓已有的词汇(packages/spec/src/api/rest-server.zod.ts:374、packages/spec/src/system/disaster-recovery.zod.ts:222 都用它表达「全部对象减去这些」),不新造名词;并且它保持「声明 = 执行」—— 排除名单是引擎读的数据,而不是藏在 handler 里的早退。
  3. 让 AI 写的元数据/代码难以出错:静态名单可打印、可在 logger.debug('Registered hook', ...) 里如实报出、可被诊断面枚举;谓词回调把任意插件代码塞进每次写入的热路径,且无法内省 —— 还会诱导插件从可变状态计算作用域,正好撞上 AGENTS.md「启动期注册表读数不得记录裁决」那一节。

影响面

Refs:#5860、#5846、#5284、#5038、#5272

Activity

  1. claude commented on Aug 6, 2026

    @claude
    Contributor

    裁决(2026-08-06,维护者批准):采纳 A 形状——excludeObjects 声明式排除面。

    registerHook options 与 HookEntry 新增 excludeObjects?: string | string[],匹配语义 matches(entry, X) = allowMatches(entry, X) && !excludeMatches(entry, X)。本单前提已对 origin/main(2381c19,立单后 main 已动)重验,全部成立;探针 D(枚举补集 ⇒ 运行期新注册对象静默不被审计)是决定性反证,方案「枚举补集」与「谓词回调」均排除。

    两处论据修正(以本评论为准,别让原表述进 ADR/changeset 叙事):

    1. 今天真实消费者只有 plugin-audit 一家(5 个注册,静态排除名单)。service-storage 的 file-reference reconcile 虽是全局注册(仅 { 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 启用时空转。
    2. 落点不止 packages/objectql:registerHook 的契约面声明在 packages/spec/src/contracts/objectql-engine.ts:141——domain:spec 标签是对的,完整文件面 = spec contracts 一小块 + objectql 实现(匹配函数、triggerHooks/hasHooksFor 两个消费点、测试)。

    实施红线(派发令必带):

    1. 单一匹配函数:triggerHooks(engine.ts:1363)与 hasHooksFor(:1396)今天是同一语义的两份手写实现——本次收敛为一个共享 matches(),并加性质 pin:门永远不得比派发更紧([17.x] 批量写按行语义实现:hook 按行触发 + record-change trigger 按行绑定 previous/record(#4800/#4862 拍板 A) #5038 注释点名的静默丢 hook 风险,这次焊死)。
    2. 拒绝语义沿姊妹裁决(hook-empty-target-not-wildcard,未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001 系):excludeObjects 里的 '' / [''] 响亮拒绝;'*' 出现在排除面 = 永不触发的 hook(ADR-0078 never-fire),同样拒绝。
    3. ⛔ 两个不做:不加 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 判例;等真实拉动再镜像。
    4. 单 PR、跨域例外路径,⛔ 不拆两段:spec contracts 半边单独先行会制造「spec 声明 excludeObjects 而引擎忽略它」的 declared≠enforced 窗口(datasource pool 声明在 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

  2. hotlong commented on Aug 7, 2026

    @hotlong
    Contributor

    Release-board audit (maintainer-directed re-audit of non-board domain:spec items, 2026-08-07): adding target:v17 — metadata-protocol change, size M, low-risk: maintainer-approved (2026-08-06) additive excludeObjects on HookEntry/registerHook, spec contracts + objectql in one PR with a single matches(); authorable HookSchema untouched, no stored data. Maintainer directive: protocol changes land in v17 unless large/risky. Triage seat may veto.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions