feat(service-knowledge): reap guard 去索引化 —— 保留期回收删行前先删索引条目 (#4672) - #5987
Merged
Conversation
…ps them (#4672) A knowledge index built from an `object` source is a per-record projection keyed by `sourceRecordId`. `LifecycleService`'s retention reap deletes rows by predicate, and ADR-0057 §3.3 forbids fanning that out as N per-record events (cleanup must not re-feed the tables it is draining), so the row disappeared and its document stayed: an orphan that still consumes a `topK` slot ahead of the permission filter and is read straight through by an `isSystem` caller. `KnowledgeServicePlugin` now registers an ADR-0057 reap guard for every object an `object` source projects. The sweep hands the guard its candidate rows before deleting them; the guard deletes each row's document by id and confirms only the rows it de-indexed. A failing `adapter.delete` VETOES the row — it is kept and retried next sweep, so a row may outlive its index entry but never the reverse. Zero new contract surface (#4606's zero-addition boundary): no spec key, no `IKnowledgeAdapter` member, no `packages/objectql` change. It is reached through the same pre-existing seams `service-storage` uses for `sys_file` byte reclaim — duck-typed `ctx.getService('lifecycle')` + `registerReapGuard`. Guards compose by intersection (#5535), so this second registrar cannot displace that byte reclaim. Application-level predicate writes stay uncovered, deliberately: #4639's warn is kept and rewritten to name that line honestly instead of promising a reconciliation pass that the 2026-08-05 ruling replaced. Tests drive the REAL `LifecycleService` rather than a stand-in, because the composition property under test belongs to the consumer — and it earned that immediately, catching a `this`-binding bug a fake registry would have passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015a5qkLzpGXhLL2F5gvJ7dD
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
hotlong
marked this pull request as ready for review
August 6, 2026 14:23
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4672
执行的是 2026-08-05 维护者批准的方案 C(reap guard 去索引化),不是正文原始的对账方案 —— 08-03 的 PM 代决已由该裁决作废,本 PR 不引用它。
问题
object知识源是逐记录投影(索引条目经sourceRecordId指向行),而LifecycleService的保留期回收按谓词删行;ADR-0057 §3.3 又禁止把它扇出成 N 条逐记录事件(清理会回灌正在清空的表)。结果:行删了,文档还在 —— 孤儿。孤儿不只是浪费存储:权限过滤发生在适配器返回topK之后,所以它会占掉名额稀释真结果,而isSystem调用方直接读到它。做法
KnowledgeServicePlugin为每个被object源投影的对象注册一个 ADR-0057 reap guard:sweep 在删除前把候选行交给 guard,guard 按 id 删除对应文档(sourceId:recordId),只确认去索引成功的行。adapter.delete失败 ⇒ veto,该行本轮保留,下轮 sweep 重试。允许「行比索引条目活得久」,绝不允许反过来 —— 与service-storage字节回收 guard 同构。service-storage的sys_file字节回收 guard,也不会被其顶掉。refresh.onRecordChange: false或插件的enableEventSync: false会同时关掉两个方向的内联同步(事件订阅 + reap 去索引)—— 复用既有开关,不新造第二个语义。零新增公开面(#4606 边界)
新增契约面 = 零。逐项列出所经过的既有 seam:
ctx.hook('kernel:ready', …)@objectstack/corePluginContextctx.getService('lifecycle')+ duck-typeservice-storage现役先例)registerReapGuard(object, guard)LifecycleServiceIKnowledgeService.listSources()/getAdapter()packages/spec/contractsIKnowledgeAdapter.delete(ids, ctx)AdapterContext.reason取值'lifecycle-reap'(string & {}),注释即「caller-supplied free-form diagnostics tag」),非新增键未新增 spec 键、未动
IKnowledgeAdapter、未动packages/objectql、未建对账子系统、未做任何适配器枚举能力(Q2 = B 现在)。包的index.ts导出面逐字未变。诚实不覆盖的那一半
应用层谓词写(调用方自己的
multi: true)仍不覆盖。#4639 的 warn 保留,并改写为准确描述这条分界(原文承诺的「reconciliation tracked in #4672」已被裁决取代,留着就是过度承诺)。content/docs/protocol/knowledge.mdx同步改正。另有一处边界写进代码注释而非隐含:对象集合在
kernel:ready读取一次;boot 后经registerSource新增的、boot 时没有任何源的对象不被守护(已有 guard 的对象则会被覆盖,因为 guard 每次调用重新解析目标)。让这个集合动态化需要在 service 上新增通知面,正是 #4606 在真实 object 源出现前排除的。前提核验(开工第一步,实测)
lifecycle-service.ts:363为Map< string, LifecycleReapGuard[] >,:480-487追加注册(同一函数重复注册是 no-op),:492-493复制读取,:1220-1230在batchedReap中按「收窄流水线」求交 —— 契约注释明写「an id is deleted only if every registered guard confirmed it」,且明确点名第二注册方(按 id 去索引派生索引)不得顶掉sys_file字节回收。符合前置,继续。sources→registerSource)驱动验证。反向验证(方向先判定,再执行)
预判:摘掉 guard 注册 ⇒ 去索引/注册类断言转红,而「无源对象不受影响」「退出开关」「无 lifecycle 静默跳过」「#4639 warn 仍在」四类保持绿(它们断言的是「知识侧什么都没发生」,在没有修复时同样成立)。
实测与预判一致:6 红 / 31 绿,红的恰好是 6 条去索引与注册断言。
测试
packages/services/service-knowledge新增 10 例 + 既有文件加 1 例,共 37 passed。覆盖裁决点名的五项:① reap 中按 id 去索引并确认;② adapter 失败 ⇒ veto(含跨两轮 sweep 仍不删);③ 与另一 guard 共存、两者都执行且互不顶掉;④ 无源对象 reap 不受影响;⑤ #4639 warn 仍在,且两半措辞都被钉住。测试驱动的是真实
LifecycleService(devDependency,照service-storage先例),不是 lifecycle 的替身 —— #5535 的交集语义是消费侧属性,用假注册表只能证明假注册表会求交(即「因为什么都没产生所以通过」的空绿)。这个选择当场见效:真实服务立刻暴露了const register = lifecycle.registerReapGuard脱离this的真 bug,假注册表会放行。新增的假引擎按
check:engine-double-contract要求以assertEngineDeleteDispatch(options)开头,并已确认被该门发现并计为 pinned(不是「没扫到所以绿」)。对 #5541 的影响
无。#5541 摘的是
service-storage的 list 面(Blocked-by spec #5540),本 PR 不碰service-storage、不碰 spec,只在lifecycle上多注册一个 guard;交集组合保证两个 guard 互不影响。已确认。Generated by Claude Code