oh-my-muse/.agents/knowledge/market-install-downstream-materialization.md
lili 3585219637
Some checks failed
Backend Maven CI / backend-local (push) Has been cancelled
feat(mvp): 收束1.0.0线A交付闭环
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:52:10 -07:00

57 lines
10 KiB
Markdown

# knowledge:市场安装下游物化 + D0-fork 知识库隔离
> **类型**:事实与蓝图(knowledge) · **范围**:market 安装如何把市场资产物化成下游可用实体,以及知识库类型资产的私有内容隔离方案 · **状态**:KB 物化 D0-fork 已实现并真验收(2026-06-26);agent handoff 物化与 KB 召回阻断已实现并真 e2e 验收(2026-06-27);副本回收等为开放项
> **关联红线**:[`../rules/security-and-reliability.md`](../rules/security-and-reliability.md)(私有不泄露须物理隔离、forkStatus fail-closed、kb_id 不存 assetId)、[`../rules/bc-boundaries.md`](../rules/bc-boundaries.md)(跨域经 -api 端口)
本文件解释一个长期被忽视的事实:**市场"安装"在很长一段时间里只是一笔账,并不真正让安装的东西能用**;以及为知识库类型资产补上这条下游链路时,如何顺带堵住一个真实的越权口子。它是 market 与 knowledge 之间这道接缝的单一归属文档,其他地方只做指针引用。
## 一、安装曾经是一笔"空账":物化断层
market 的安装流程原本只写两样东西——`muse_market_installation` 安装记录和 license 授权投影——然后就结束了。它和 ai、knowledge 这些真正承载内容的模块完全解耦(安装侧代码与 pom 都不依赖它们,双向都验证过)。换句话说,用户在市场点了"安装",系统记下了"这个人有权用这个资产",却没有在任何下游模块里把资产变成可检索、可生成、可引用的真实实体。这就是"物化断层"。
这个断层在知识库资产上表现为两个具体断点:
第一个是**知识库断点**。安装一个 KB 类型资产后,本地没有对应的 `muse_knowledge_base` 行,也没有可检索的 RAGFlow dataset。于是当用户把它绑到作品、发起检索时,检索链路走到第四道门 `selectActiveDatasetByKbId` 拿不到 dataset,这个来源就被静默地以 `no_dataset` 理由省略掉了——检索看起来正常返回,实际上这个知识库从未参与。更隐蔽的是,绑定时把 `binding.kb_id` 写成了市场资产 id 而不是本地知识库主键,导致这个字段从源头就被污染,后续任何按 kbId 的查询都注定落空。
第二个是**agent 断点**。安装 agent 类资产后,虽然授权槽位放宽了,但 AI 运行时的 `requireVisibleAgent` 仍会拒绝裸引用一个没有本地实体的 agent。这个断点已在 E4 补齐:market agent handoff 只把 assetId/token 交给目标 owner,AI 后端按 market asset `source_id` 解析发布者 agent,再物化为安装者本地 user agent,槽位绑定指向本地副本,运行时自然按本地可见 agent 过门。活体 e2e 已证明该槽位可创建并完成真实 AI task。
## 二、真正的越权:不是安装者之间,而是安装者读到发布者私有内容
补这条链路时澄清了一个关键的、容易被误判的安全问题。
它**不是**"安装者之间互相越权"。在只读共享的语义下,所有安装者看的本就是同一份发布者发布的内容,大家看到全部公开内容是符合预期的,没有问题。
真正的越权是**安装者读到了发布者的私有内容**。原因在于知识库的生命周期在上架后并不冻结:发布者把整个 KB 上架到市场后,仍然可以继续往自己这个 KB 里上传新文档,而上传动作没有任何市场门禁。如果安装者直接共享发布者那一份**活的** dataset,那么发布者上架之后才加进去的私有文档,会被整库检索读到——安装者于是看到了从未被发布的私有 chunk。租户拦截器在这里拦不住,因为安装者读的就是发布者那一份 dataset 本身。
## 三、D0-fork 解法:上架即 fork 一份只含公开快照的专用副本
解决思路是在**上架那一刻**,由发布侧 fork 出一份专用 dataset,只包含上架审核时刻处于可检索状态的公开文档快照。安装者只读这份公开副本,与发布者的活 dataset 物理隔离。这样发布者事后往自己 KB 加多少私有内容都进不了副本,越权从源头被斩断。
这个方案有几个刻意的取舍:副本对每个被上架的资产版本只有一份(不是每个安装者一份),多个安装者共享同一份只读副本;它**不依赖运行时按 chunk 过滤**(后述 metadata 字段有 bug,刻意不踩),而是靠"副本物理上就只有公开内容"这一事实来保证隔离,因此检索侧零改动。
整套机制由四个单元构成,已全部实现并通过真 PG + 真 RAGFlow 的集成测试验收。
**U-fork(发布侧 fork 公开副本)**。知识库上架(market 侧 `markListed`)时,在已提交的事务里发一个进程内 Spring 事件;knowledge 侧的消费者以 `@TransactionalEventListener(AFTER_COMMIT)` + `@Async` 异步接收,调用 `KnowledgeMarketForkService` 执行 fork:建一个新 dataset,逐篇回读公开文档内容、上传、触发重索引。RAGFlow 没有 copy API,所以 fork 必须重走"建库 + 逐文档上传 + 重新解析索引"这条完整路径,不能共享发布者的索引。整个 fork 是一个多次外部调用的异步长任务,按外部交互铁律处理了幂等(键是资产 id + 版本,已 ready 则整体跳过)、部分成功(只补未成功的文档差集)、失败分类(临时故障可重试、校验类不可重试)、以及一个 `@Scheduled` 兜底 worker 扫描中间态补做。fork 的成败**绝不**反向影响 market 的上架审核——审核在自己的事务里落完上架事件就返回,fork 是 knowledge 自治的后续异步活。副本 dataset id 和 fork 状态机(pending→partial→ready/failed)存在 knowledge 自有 binding 表的 summary 里(不加 DDL),并写回 market 资产的 tags 供安装侧读出。
**U-materialize(安装侧物化)**。用户把市场 KB 绑到作品时,预检阶段经 `MarketAssetSourceApi` 这个 market 对外读端口,跨 BC 读资产来源 + 公开副本状态(经 -api 端口,不直读 market 的 dal,这是 BC 边界硬约束)。这里做三层 fail-closed 校验:资产必须存在、必须是 knowledge_base 类型、且 `forkStatus` 必须为 `ready`——副本还在 fork 中(pending/partial/failed)一律拒绝绑定,让用户稍后重试,绝不建出一个"绑了但检索不到"的迷惑实体。校验通过后把物化所需信息固化进预检快照,在用户确认绑定的写事实点,把公开副本物化成一个本地 `installed_ref` 类型的知识库实体(它引用市场副本、自身不持有文档,与 user/global KB 区分),并建一条指向副本 dataset 的绑定。这里同时**去污染**:`binding.kb_id` 一律落本地新建的知识库主键,不再是市场资产 id。物化对同一安装者同一资产幂等(命中既有 installed_ref KB 则复用);多个安装者各建自己的本地 KB 行,但它们的 dataset 绑定都指向同一份公开副本。
**U-retrieve(检索打通)**。检索 API 实现**零改动**。前两个单元做完之后,本地有了 installed_ref KB、有了指向公开副本的 dataset 绑定,检索走到第四道门 `selectActiveDatasetByKbId` 自然命中,原先的 `no_dataset` 静默省略消失。因为副本物理上只含公开内容,整库检索即纯公开内容,无需任何运行时隔离。
**U-verify(端到端真验收)**。两个集成测试在真 PG + 真 RAGFlow 上验证完整链路:上架 fork → 安装物化 → 检索命中,且发布者上架后才加的私有探针文档**检索不到**(隔离成立);另一个用例用真 Spring 上下文 + 真事务验证 AFTER_COMMIT 时序——事务提交才触发 fork、事务回滚则不投递事件,把 fork 接线从 mock 单测层抬到真事务层。
## 四、几条关键的架构衔接事实
**为什么用进程内 Spring 事件做跨模块通知**。muse 的业务模块此前没有用过 Spring ApplicationEvent 的先例;项目里那套统一的 Events 机制是给前端 SSE 用的、后端没有消费方。所以这次跨模块从 market 通知 knowledge,选了进程内 Spring 事件这条路(由人类拍板)。配合 AFTER_COMMIT,保证只有上架真正提交后才触发 fork。
**跨 BC 读经 -api 端口、读写分离**。安装侧读 market 资产来源走 `MarketAssetSourceApi`(读端口),发布侧把 fork 状态写回 market 走 `MarketAssetForkApi`(写端口)。两者调用方、方向、生命周期都不同,按读写分离拆成两个窄端口,避免两类消费者互相依赖对方用不到的方法。两个端口都是本地进程内 Bean、不挂 Feign(market 与各 owner 同进程聚合)。knowledge 全程不碰 market 的 dal。
**kb_id 去污染是检索能否命中的命门**。市场 KB 绑定请求里的 sourceId 是市场资产 id,不是本地 kbId。一旦把它当主键写进 binding.kb_id,后续按 kbId 的检索查询永远落空、表现为静默 no_dataset。所以 user/global KB 沿用 sourceId 即本地主键,market KB 则在预检阶段留空、在物化后回填本地新建的 kbId。这条已上升为红线(见 security-and-reliability.md)。
**召回只发布事实,停用由目标 owner 自治执行**。market 召回资产时只拥有市场治理事实,不能直接改 knowledge/ai/content 的本地投影。E4 以后,`recallAsset` 在同事务写 market source status 记录并发布 `MarketAssetSourceStatusChangedEvent`;Knowledge 用 AFTER_COMMIT 监听该事件,按本域模型把已物化 installed_ref KB 的 projection 改成 `recalled/blocked`。检索 API 的来源状态门已能据此把该 KB 作为 omitted source 返回,不再送进 RAGFlow。
## 五、仍未闭环
- **副本资源回收待做**。资产被删/版本更替后,旧的公开副本 dataset 没有清理路径。
- **下架/撤权的细粒度补偿仍待设计**。E4 已覆盖 recall→installed_ref 停检索的合规底线;delist/revoke 的差异化策略、已运行任务补偿、生产者自助入口仍后置。
- **metadata_filter 字段 bug 待修(已知、刻意未依赖)**。代码里发的是 `metadata_filter`,而 RAGFlow 官方契约是 `metadata_condition`,字段名不匹配被 RAGFlow 静默忽略。D0-fork 刻意不依赖运行时 metadata 过滤(改用物理隔离),所以这个 bug 目前潜伏不影响隔离;但它仍是一个待修的真实缺陷。详见 [`external-deps-and-gotchas.md`](external-deps-and-gotchas.md) 的 RAGFlow 集成坑。