# 临时-04 · Market KB 物化 D0-fork(发布侧 fork 纯公开副本)执行 Plan
- 版本:v1(执行版,待人类 review/拍板后执行;本稿只读出文档,不改任何代码)
- 更新日期:2026-06-26
- 目标读者:后端 / 架构 / PR reviewer
- 阅读时间:30–40 分钟
- 文档性质:**临时件**(执行依据,落定后归并入正式分册或删除,不作长期 SSOT)。本稿只给"怎么做",不重定义概念:方案演进与 C/B'/暂缓权衡见 [`临时-02-market-install下游物化方案.md`](临时-02-market-install下游物化方案.md);上一版(共享发布者活 dataset + 运行时 chunk 隔离)见 [`临时-03-market-install-KB物化执行plan.md`](临时-03-market-install-KB物化执行plan.md)(**已被本稿 supersede**,保留作决策演进史);术语与 owner 边界以 [`架构-02-核心数据结构与双轨模型.md`](架构-02-核心数据结构与双轨模型.md) 为准(§6 市场资产、§11.2 绑定安装授权、授权快照"引用对象必须保存快照 id");表结构归 `后端-04`;检索合同归 [`专题-03`](专题-03-AI编排上下文与质量评测实现规范.md) §5;授权快照字符串承载归 ADR-020;Source 传播事件驱动归 ADR-017;BC 边界规则归 [`.agents/rules/bc-boundaries.md`](../.agents/rules/bc-boundaries.md)。
- 配套人读图:[`临时-04-market-KB物化-D0fork执行plan.html`](临时-04-market-KB物化-D0fork执行plan.html)
> **一句话**:拍板已定——做 **D0-fork**:知识库上架时,在**发布侧** fork 出一份**只含公开文档的专用 dataset**("公开副本"),所有安装者只读共享这一份副本。这从物理上把发布者的私有内容隔在副本之外,从根上消除"安装者读到发布者私有内容"的越权,**不需要运行时 chunk 隔离、不依赖那个 `metadata_filter` 字段 bug**。代价是新增一块发布侧的 fork 工程(建副本 dataset + 逐文档复制 + 重索引,异步重活),并要打破"market 对 knowledge 零依赖"的现状接线。本 plan 把 D0-fork 拆成 **U-fork / U-materialize / U-retrieve / U-verify 四个可独立验证的实现单元**。
>
> **越权真因(D1 澄清坐实,区别于临时-03 的假设)**:临时-03 当时把越权理解为"多租户共享同一 dataset → 安装者**之间**互读",方向定在"共享前补 chunk 级隔离"。D1 澄清重新坐实了更要命的一条:真越权是**安装者读到发布者的私有内容**。链条是——market 对 knowledge/ragflow **零依赖**(上架审核 `markListed` 只翻 market 自有资产状态,从不 fork 任何 dataset)→ 安装者绑定后若走"共享"必然指向**发布者那一份活 dataset**→ 而发布者**上架后仍可往自己 KB 继续加私有文档**(whole-KB 上架、KB 生命周期不冻结)→ 安装者检索这份活 dataset 就会读到上架时根本不存在、发布者事后才加的私有内容。**这不是租户拦截器能拦的**(两边 binding 指向同一个 RAGFlow dataset_id,检索链对返回 chunk 无任何二次过滤,见 §1 事实 1)。D0-fork 用"复制一份只含公开快照的独立 dataset"把这条链从物理上斩断。
---
## 0. 与临时-03 的差异(读过临时-03 的人只需看这一节就能对齐)
| 维度 | 临时-03(共享发布者活 dataset,superseded) | 临时-04(D0-fork,本稿) |
|---|---|---|
| 越权模型 | 安装者**之间**越权(多租户共享 dataset) | 安装者读到**发布者私有**内容(上架后新增/未公开文档) |
| 隔离手段 | 运行时 chunk 隔离(`metadata_filter`/`document_ids` 把 chunk 限到本授权子集) | **物理隔离**:fork 一份只含公开文档的专用 dataset,安装者只读它 |
| 检索时 dataset | 发布者活 dataset(共享) | **公开副本 dataset**(与发布者活 dataset 物理分离) |
| `metadata_filter` 字段 bug | U3 隔离前置必须先修(`metadata_filter`→`metadata_condition`) | **降级为潜伏 bug**:不在 D0-fork 关键路径(公开副本纯公开、检索不靠它过滤);单独记、留待修 |
| 核心新增工程 | chunk 级隔离 + 物化时给文档打安装维度元数据 | **发布侧 fork 公开副本**(建 dataset + 复制文档 + 重索引,异步重活)+ 打破 market→knowledge 零依赖接线 |
| 复制方向 | 否决了"每安装者各复制一份"(C 分支) | fork **一份**公开副本、N 个安装者共享只读(副本仅 1 份,非每安装者一份) |
**临时-03 哪些资产被本稿继承**:
- **U0 spike 的 2 个 spike case 保留**:机制事实"检索链对返回 chunk 无 chunk 级过滤"(混入他源 magic chunk 原样返回 + `RetrieveChunksCommand` 的 `documentIds`/`metadataFilter` 均 null = 整库扫描)仍然成立、仍是 D0-fork 必须斩断越权的根因证据;它在本稿转作 **U-verify 的反向基线**——用来证明"发布者私有不泄露"(fork 后安装者检索副本,混入副本之外的发布者私有 magic chunk 应**检索不到**)。spike 测试已落 `MuseKnowledgeRetrievalApiImplTest`(+69 行,零业务代码),不必重写。
- **临时-03 的四条硬事实**(kb_id 被 assetId 污染、跨 BC 反查链断需新 `MarketAssetSourceApi`、`muse_source_propagation_target` 表不存在、检索链无 chunk 二次过滤)**全部仍然有效**,是本稿 U-materialize/U-retrieve 的地基,§1 复述。
---
## 1. 执行前必读:把工作量钉死的硬事实(基于真实代码,行号为只读核验所得)
**事实 1 — 检索链对返回 chunk 无任何二次过滤,"谁能进检索、就看到该 dataset 整库"。这是 D0-fork 必须做物理隔离、而非运行时隔离的根因。**
RAGFlow 检索 HTTP body(`HttpRagFlowKnowledgeRuntimeClient.retrieveChunks:144-157`)只发 `dataset_ids + question + top_k + similarity_threshold + metadata_filter`,`tenantId/ownerUserId/kbId` **不进 RAGFlow**。检索链 `MuseKnowledgeRetrievalApiImpl.retrieveForWork`:双门 + `selectActiveDatasetByKbId` 只控制"能否拿到这个 dataset_id",一旦 dataset_id 进入检索,`document_ids` 传 null(`:149` 第五个参数 null)→ **整库返回**;`parseChunks:171-198` 回来只按 dataset_id 贴归因元数据,**对 chunk 无任何按授权子集的二次过滤**。**结论**:只要安装者 binding 指向发布者活 dataset,就能读到该 dataset 里发布者后加的私有 chunk。D0-fork 让安装者 binding 指向的是**只含公开快照的副本 dataset**,从源头让"整库返回"返回的也只有公开内容。
**事实 2 — 那个 `metadata_filter` 字段在 RAGFlow 侧被静默忽略(潜伏 bug,D0-fork 下不在关键路径)。**
检索发 `body.put("metadata_filter", ...)`(`HttpRagFlowKnowledgeRuntimeClient.retrieveChunks:155`),但 RAGFlow `/api/v1/retrieval` 官方契约字段是 `metadata_condition`(context7 `/infiniflow/ragflow` 核对 + 临时-03 活体对照:乱值条件仍返 baseline),故当前 `metadata_filter` 被 RAGFlow 静默忽略、根本不过滤。**结论**:临时-03 的共享分支要靠它做隔离,所以必须先修;**D0-fork 不靠它**(公开副本纯公开,检索不需要再按维度过滤),故此 bug 在本期**降级为潜伏 bug**——`§7 风险 R6` 单独记、留待后续修,不进 D0-fork 关键路径。
**事实 3 — `kb_id` 字段当前被污染:存的是 market assetId,不是真实 `muse_knowledge_base.id`。**
`MuseKnowledgeBindingService.createKnowledgeBindingPrecheck:110` `precheck.setKbId(parseLong(reqVO.getSourceId()))`,studio 传入的 `sourceId` 实为 market **assetId**;`createKnowledgeBinding:148` `binding.setKbId(precheck.getKbId())` 原样进 `muse_knowledge_binding.kb_id`。**结论**:物化要把 `kb_id` 从 assetId 改写成新建本地 installed_ref kb 行的真实主键,并保证检索/读回端口跟着改对。这是 U-materialize 的核心难点。
**事实 4 — `assetId → 发布者 kbId` 反查链数据上通、跨 BC 上断;market 对外 API 包当前只有 `MarketHandoffTokenApi`。**
`muse_market_asset.source_id`(`V6:10` BIGINT)在审核物化资产时写入发布者本地 kbId(`AdminMarketReviewServiceImpl.resolveApprovedAssetBinding:454` `asset.setSourceId(longValue(draftSnapshot.get("sourceId")))`)。但 knowledge 不能直读 market 的 `.dal`(ArchUnit `BcBoundaryArchTest`),market `api` 包下**目前只有 `MarketHandoffTokenApi`(verify/consume)**,没有"按 assetId 查源对象/查公开副本 dataset"的读接口。**结论**:D0-fork 下安装侧物化要拿到"公开副本 dataset id",必须**新增跨 BC 读端口**(`MarketAssetSourceApi.getAssetSource(assetId) → {sourceType, sourceId=发布者kbId, publisherUserId, publicForkDatasetId, forkStatus}`,本地进程内 Bean,与 `MarketHandoffTokenApi` 同范式、不加 Feign)。**注意 handoff token 不带这个信息**:token snapshot(`MarketHandoffServiceImpl.handoffSnapshot:450`)只含 assetId/authorizationSnapshotId/targetWorkId/targetPage,没有发布者 kbId/dataset,故拿不到副本 dataset,必须走这个新读端口。
**事实 5 — 发布快照 `muse_knowledge_publish_snapshot` 不含文档子集,印证"上架时刻 KB 全部文档 = 公开集"。**
`MuseKnowledgePublishService.createKBPublishSnapshot:140-159` 写入的快照只有 `sourceSnapshotId`/`authorizationSnapshotId`/`packageHash` 等字符串,**没有"哪些文档算公开"的文档清单**(DO `MuseKnowledgePublishSnapshotDO` 也无文档子集字段)。**结论**:因为 draft/上架没有"公开文档子集"概念,**whole-KB 上架时刻 KB 的全部文档即公开集**(U-fork §3.3 据此界定)。这也意味着 fork 的"公开快照"= 上架审核那一刻 KB 的全部文档版本,发布者**上架后新增的文档不属于这个快照、不进副本**——这正是隔离发布者私有的语义支点。
**事实 6 — dataset 与文档复制无现成 RAGFlow "copy" API;fork 必须重走 createDataset + 逐文档 upload + startParse(重索引)。**
`RagFlowKnowledgeRuntimeClient` 接口(11 个操作)有 `createDataset`/`uploadDocuments`/`startParseDocuments`,**没有 copyDataset/copyDocument**。现有上传链路 `MuseKnowledgeDocumentService.continueRagflowIfMaterialized:248-298` 的形态是:`ensureDatasetBinding`(懒建 dataset)→ `uploadDocuments`(带 `fileRef`/`content` byte[])→ `startParseDocuments`(触发解析/索引)。**结论**:fork 一份副本 = 新建一个副本 dataset(`createDataset`)+ 把发布者每个公开文档的物化文件(`muse_knowledge_document_version.storageRef` + 物化内容)逐个 `uploadDocuments` 到副本 + 逐个 `startParseDocuments` 重索引。这是 **异步长任务重活**(多次外部调用、可能失败/部分成功),是 D0-fork 相对临时-03 最大的新增成本,U-fork §3.5 据此设计。
**事实 7 — market 召回/下架对目标 owner 的传播当前是 fail-closed blocked 记录式,knowledge 侧无消费入口。**
`AdminMarketGovernanceServiceImpl.writeSourceStatusBlocked:361` 把召回/下架的目标 owner 传播落成 `propagationStatus=blocked / reason=target_owner_unavailable / errorCode=TARGET_OWNER_UNAVAILABLE`,注释自证"Market Task 9 未接入目标 owner 传播"。**结论**:召回/下架回滚到 knowledge installed_ref + 副本 dataset 的自动化,本期是**开放项**(U-verify §回滚 + 决策点 D5 如实标,不假装已闭环;也不引用临时-03 已证伪的 `muse_source_propagation_target` 表)。
**事实 8 — installed_ref 落点的列与唯一键已就绪,B' 共享/D0-fork 初版均不需要新 DDL(除可选 fork 状态列,见 §6)。**
`muse_knowledge_base`(`V5:4-28`)已备 `kb_type`/`source_market_asset_id`(BIGINT)/`license_snapshot_id`(BIGINT);`muse_knowledge_ragflow_binding`(`V14`)唯一键 `uk_..._active = (tenant_id, kb_id) WHERE status='active' AND document_id IS NULL AND deleted=FALSE`——副本 dataset binding 挂在**新建本地 installed_ref kbId** 上不撞发布者 kb 的键。**结论**:安装侧物化(U-materialize)落在 V5/V14 已有列,**不需要新迁移**;唯一可能需要 DDL 的是发布侧"副本 dataset id + fork 状态"的存放(§6 给两种落点,倾向复用 JSONB 不加列 → 仍不需要迁移)。
> 这八条把 D0-fork 的真实成本钉死:**核心增量 = 发布侧 fork 公开副本(事实 5/6,异步重活,全新)+ 打破 market→knowledge 零依赖接线(事实 4/7)+ kb_id 去污染 + 新跨 BC 读端口(事实 3/4)**;运行时隔离与 `metadata_filter` 修复(临时-03 的核心增量)在 D0-fork 下**不需要**。
---
## 2. 总览:实现单元、数据流与 fork 时序
### 2.1 实现单元依赖图
```mermaid
flowchart TD
UFORK["U-fork(发布侧 fork 公开副本,核心新工程)
上架事件 → knowledge 异步建副本 dataset
+ 逐文档复制 + 重索引 + 幂等/重试/部分成功"]
UMAT["U-materialize(安装侧物化,原 U2 简化去隔离)
建 installed_ref kb 行 + binding 指向公开副本 dataset
kb_id 去污染 + 新 MarketAssetSourceApi 跨 BC 读 + 幂等"]
URET["U-retrieve(检索打通,原 U3 简化)
selectActiveDatasetByKbId 返非空→命中
无运行时隔离(副本纯公开)+ 授权快照 ADR-020 VARCHAR"]
UVER["U-verify(验证)
真 PG IT(物化→检索命中)+ studio e2e
+ 发布者私有不泄露(复用 U0 反向基线)+ 召回/下架回滚"]
UFORK --> UMAT
UMAT --> URET
URET --> UVER
DEC{"前置决策门(人类拍板,§5)
fork 时机:上架 vs 懒 fork(倾向上架)
接线:事件驱动 vs facade(倾向事件)
副本更新策略:重新上架才更新(版本快照语义)"}
DEC --> UFORK
style UFORK fill:#fde2e2,stroke:#c0392b,stroke-width:3px
style UMAT fill:#e2f7e8,stroke:#27ae60
style URET fill:#e2f7e8,stroke:#27ae60
style UVER fill:#fff6d6,stroke:#b8860b
style DEC fill:#eef3fb,stroke:#3b6fb6,stroke-width:2px
```
### 2.2 物理隔离:发布侧 vs 安装侧(D0-fork 的越权解法)
```mermaid
flowchart LR
subgraph PUB["发布者域(owner = 发布者)"]
LIVE["发布者活 dataset
kb-{发布者kbId}-v{ver}
含公开 + 上架后新增私有文档"]
FORK["U-fork: 公开副本 dataset
fork-asset-{assetId}-public
只含上架时刻公开快照文档"]
LIVE -.上架时刻公开快照
逐文档复制+重索引.-> FORK
end
subgraph INS["安装者域(owner = 各安装者)"]
IREF["installed_ref kb 行(每安装者一行)
kb_type=installed_ref
source_market_asset_id=assetId"]
IBIND["ragflow_binding
kb_id=本地installed_ref kbId
ragflow_dataset_id=公开副本"]
end
FORK ==>|N 安装者共享只读
副本仅 1 份| IBIND
IREF --> IBIND
IBIND --> RET["检索 selectActiveDatasetByKbId
→ 公开副本 dataset → RAGFlow 整库返回
🟢 整库 = 纯公开,发布者私有物理不在副本内"]
style LIVE fill:#fde2e2,stroke:#c0392b
style FORK fill:#e2f7e8,stroke:#27ae60,stroke-width:2px
style RET fill:#e2f7e8,stroke:#27ae60
```
### 2.3 fork 时序(上架触发 + 事件驱动 + 异步重活,含失败/重试/幂等)
```mermaid
sequenceDiagram
participant Admin as 管理员审核
participant MReview as AdminMarketReviewService(market)
participant Outbox as market 上架事件(outbox/source event)
participant KConsumer as Knowledge fork 消费者(knowledge)
participant KFork as KnowledgeMarketForkService(knowledge)
participant RAG as RAGFlow
Admin->>MReview: approvePublishRequest(含 markListed)
MReview->>MReview: 资产/版本 listed(market 自有事实,同事务)
MReview->>Outbox: 落上架事件 listed{assetId, 发布者kbId, version}(同事务,不等 fork)
Note over MReview,Outbox: 审核事务到此提交,不被 fork 成败绑架
MReview-->>Admin: 审核通过(fork 异步进行中)
Outbox-->>KConsumer: 异步派发上架事件
KConsumer->>KFork: forkPublicSnapshot(assetId, 发布者kbId, version)
KFork->>KFork: 幂等检查(已 fork 过该 assetId+version?)命中则跳过
KFork->>RAG: createDataset(fork-asset-{assetId}-public)
loop 每个上架时刻公开文档
KFork->>RAG: uploadDocuments(副本dataset, 文档物化内容)
KFork->>RAG: startParseDocuments(副本dataset, 重索引)
end
alt 全部成功
KFork->>KFork: forkStatus=ready + 记副本 datasetId(供 MarketAssetSourceApi 带回)
else 部分失败/RAGFlow 502
KFork->>KFork: forkStatus=partial/failed + 标可重试(不阻断已建部分)
KConsumer->>KFork: 重试(幂等:已成功文档不重复传)
end
```
### 2.4 安装侧物化 + 检索数据流(D0-fork 后)
```mermaid
flowchart LR
A["用户跳转→knowledge 绑定路径
createKnowledgeBindingPrecheck/Binding"]
A --> B["消费 handoff token(原有,不动)"]
B --> C["U-materialize: MarketAssetSourceApi.getAssetSource(assetId)
带回 {发布者kbId, publicForkDatasetId, forkStatus}"]
C --> D{forkStatus=ready?}
D -->|否,fork 中/失败| E["fail-closed: 暂不可绑定/绑定但检索 no_dataset
(决策点 D6)"]
D -->|是| F["建本地 muse_knowledge_base 行
kb_type=installed_ref, source_market_asset_id=assetId
license_snapshot_id=授权快照"]
F --> G["建 muse_knowledge_ragflow_binding
kb_id=新建本地kbId, ragflow_dataset_id=公开副本"]
F --> H["binding.kb_id=新建本地kbId(去污染,不再是 assetId)"]
H --> I["检索 selectActiveDatasetByKbId(本地kbId)
→ 公开副本 dataset → 🟢 命中且纯公开"]
G --> I
style C fill:#fff6d6,stroke:#b8860b
style F fill:#e2f7e8,stroke:#27ae60
style G fill:#e2f7e8,stroke:#27ae60
style I fill:#e2f7e8,stroke:#27ae60
style E fill:#fde2e2,stroke:#c0392b
```
---
## 3. 实现单元
> 文件路径均为 repo 相对路径。每个单元含 Goal / Files / Approach / Test scenarios(输入+预期)/ Verification。测试场景"输入"指具体调用入参或数据状态,"预期"指可断言的可观测结果。
### U-fork · 发布侧 fork 公开副本(核心新工程)
**Goal**:知识库上架时,在发布侧把"上架时刻 KB 的全部公开文档"fork 成一份**专用公开副本 dataset**(建副本 dataset + 逐文档复制内容 + 重索引),并把副本 dataset id + fork 状态留存,供安装侧 `MarketAssetSourceApi` 带回。物理隔离发布者私有内容(副本只含上架快照、发布者上架后新增不进副本)。**这是 D0-fork 相对临时-03 全新的、工程量最大的一块。**
**Files**:
- 新增 knowledge 侧 fork 服务(核心):`muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/KnowledgeMarketForkService.java`(新)
- 新增上架事件消费者(接线,事件驱动方案):`muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/KnowledgeMarketListedEventConsumer.java`(新)
- 复用范本(fork 复制三步的写法):`.../knowledge/application/muse/MuseKnowledgeDocumentService.java`(`ensureDatasetBinding:300-319` createDataset 形态、`continueRagflowIfMaterialized:248-298` upload+startParse 形态、`persistDatasetBinding:321-347` 数据集级 binding 写法)
- 复用运行时边界(无需改接口):`.../knowledge/application/muse/facade/RagFlowKnowledgeRuntimeClient.java`(`createDataset`/`uploadDocuments`/`startParseDocuments` 已够用,事实 6)
- 发布侧上架事件落点(接线,事件驱动方案,market 侧):`muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/application/muse/AdminMarketReviewServiceImpl.java`(`markListed:338-358`/`approvePublishRequest` 内落上架事件)
- 副本 dataset id + fork 状态存放:见 §6(倾向复用 `muse_knowledge_ragflow_binding` 数据集级行 + `muse_market_asset.tags` JSONB,不加 DDL)
- 读取发布者公开文档清单与物化内容:`.../knowledge/dal/mysql/muse/MuseKnowledgeDocumentMapper.java` / `MuseKnowledgeDocumentVersionMapper.java`(按发布者 kbId 读 active 文档版本 + storageRef)+ `.../facade/KnowledgeFileFacade.java`(物化内容回读,复用上传链路同源)
**Approach**:
**① fork 时机(决策点 D4,给推荐 + 理由)**——两个选项中立呈现,**推荐"上架 `markListed` 时 fork"**:
- **选项 A(推荐):上架时 fork**。审核通过 `markListed` 落 market 资产 listed 事实的同时,发上架事件触发 knowledge 异步 fork。
- 优点:① **版本快照语义最干净**——副本 = 上架审核那一刻 KB 的公开集,天然绑 `muse_market_asset_version` 版本,契合"市场资产 = 发布快照"既有模型(事实 5);② **安装侧保持轻**——安装者绑定时副本已就绪,只建 installed_ref kb + binding(与 token 消费同事务原子,无异步等待,U-materialize 干净);③ 浪费可控——上架是低频审核动作,上架本就意味着"打算供人安装"。
- 代价:未必有人安装就先占一份 RAGFlow dataset 资源(浪费);且 `markListed` 当前是 market 域纯资产状态更新,要打破 market→knowledge 零依赖触发 fork(接线问题,见②)。
- **选项 B:首次安装懒 fork**。第一个安装者绑定时才触发 fork。
- 优点:无人安装则不浪费 RAGFlow 资源。
- 代价:① **首装者承受 fork 全程异步延迟**(建 dataset + 逐文档上传 + 重索引是长任务);② **版本快照语义被破坏**——懒 fork 时刻发布者 KB 可能已变(已加私有文档),要么上架时先冻结文档清单(等于把"确定公开集"的工作提前到上架,等于 A 的一半),要么懒 fork 复制的不再是"上架时刻公开集"(隔离语义失守);③ 并发首装需抢锁 + 幂等防重复 fork。
- **权衡结论**:浪费(A 的代价)比"安装延迟 + 版本快照语义失守 + 并发抢锁"(B 的代价)更可接受,且 A 的语义正确性是隔离成立的前提。**推荐 A**。RAGFlow 资源浪费可后续用"下架/无安装超时清理副本"缓解(非本期,§7 R5 标)。
**② 打破 market→knowledge 零依赖的接线(决策点 D5,给推荐 + 理由)**——**推荐"事件驱动(market 落上架事件 → knowledge 监听异步 fork)"**:
- **选项 A(推荐):事件驱动**。`markListed` 在审核事务内落一条 market 自有"上架事件"(复用 outbox/source event 模式,如 `MuseMarketSourceStatusEventDO` 同构或新事件类型),knowledge 侧消费者异步消费触发 fork。
- 优点:① **审核事务不被 fork 成败绑架**——审核只管落 market 资产事实 + 发事件(快、不挂在 RAGFlow 上、RAGFlow 502 不回滚审核);② **契合已拍板 ADR-017**(Source 传播 = 事件驱动 + 各模块自治);③ **保 BC 边界**——market 不新增对 knowledge 的依赖,knowledge 作为消费者自治 fork(与现有 `muse_knowledge_source_event` 消费模式同构);④ fork 失败在 knowledge 侧重试,天然契合异步重活。
- 代价:需建 market→knowledge 的上架事件投影 + knowledge 消费者(比 facade 直调工程量大),且引入"fork 异步、安装时副本可能未就绪"的中间态(U-materialize 须 fail-closed 处理,见 U-materialize ④ / 决策点 D6)。
- **选项 B:market 调 knowledge facade**。`markListed` 同步调 `KnowledgeMarketForkApi.forkPublicSnapshot(...)`。
- 优点:实现直接、无事件基础设施、无"副本未就绪"中间态。
- 致命缺点:① **fork 是异步重活,塞进审核同步事务 = 审核请求挂在 RAGFlow 多次调用上**,失败则审核回滚(审核不该被 fork 成败绑架);② **方向是 market→knowledge 的新反向依赖**(当前 knowledge→market 已有 `MuseKnowledgeBindingService` 依赖 `MarketHandoffTokenApi`),要 knowledge 定义端口、market 适配(BC 上可行但与异步重活天然冲突)。
- **权衡结论**:fork 异步重活 + ADR-017 已定调事件驱动,**推荐 A**。代价(事件设施 + 副本未就绪中间态)是正确解耦的必要成本,如实计入工程量。
**③ whole-KB 公开快照界定**:fork 的"公开集"= **上架审核那一刻发布者 KB 的全部 active 文档版本**(事实 5:draft/发布快照无文档子集概念,whole-KB 上架)。fork 消费上架事件时按 `发布者 kbId` 读 `muse_knowledge_document` + 各文档 `selectLatestByDocumentId` 的 active 版本作为复制清单。**发布者上架后新增的文档**因 create_time 晚于上架事件,**不在这一次 fork 的清单内**——这就是隔离发布者私有的语义支点。(若上架时刻有 `scan_blocked`/`failed`/`deleting` 的文档,fork 应跳过,只复制 `searchable`/`generative` 可检索文档。)
**④ 复制机制(事实 6)**:fork 一份副本 dataset 三步走,复用上传链路同源逻辑:
1. `ragFlowClient.createDataset(CreateDatasetCommand)`:副本 dataset 名建议 `fork-asset-{assetId}-v{version}-public`(区别于发布者活 dataset `kb-{kbId}-v{ver}`),config 带 `source=market_public_fork, assetId, version`。
2. 对上架清单每个公开文档版本:从 `muse_knowledge_document_version.storageRef` + `KnowledgeFileFacade` 回读物化内容(byte[]/fileRef,与发布者上传时同源),`ragFlowClient.uploadDocuments(UploadDocumentsCommand)` 上传到副本 dataset。
3. 对每个已上传文档 `ragFlowClient.startParseDocuments(StartParseDocumentsCommand)` 触发副本侧重解析/重索引(副本是独立 dataset,必须重新索引,不能共享发布者索引)。
**⑤ 异步重活:失败/重试/幂等/部分成功(CLAUDE.md 外部交互铁律)**:
- **幂等键**:`(assetId, version)` 维度。fork 前先查"该 assetId+version 是否已 fork"(按副本存放落点查,§6),命中且 `forkStatus=ready` 则整体跳过;命中但 `partial/failed` 则只补未成功的文档(已成功文档不重复上传)。
- **部分成功**:建副本 dataset 成功但部分文档上传/解析失败 → `forkStatus=partial`,记已成功文档集,**不删已建部分**,标可重试;下次消费/重试只补差集。
- **失败分类沿用**:RAGFlow 调用失败按 `RagFlowKnowledgeRuntimeClient.FailureClass` 分可重试(`RAGFLOW_UNAVAILABLE`/`TIMEOUT`/`RATE_LIMITED`/`CONFIG_MISSING`/`AUTH_MISSING`)与不可重试;可重试走消费者重试,不可重试落 `forkStatus=failed` + errorCode 供人工介入。
- **审计**:fork 的每次 RAGFlow 调用复用 `MuseKnowledgeAuditService.recordRagflowCall`(`attributionStatus` 标 `market_public_fork`),correlation 区分 operation 防唯一键冲突(同 `auditRagflowCall` 范式)。
- **重索引耗时**:startParse 是异步触发,副本文档真正 `searchable` 需 RAGFlow 后台解析完成;`forkStatus=ready` 的判定应是"全部文档 startParse 已 accepted"(而非"已 searchable"),searchable 由 RAGFlow 后台推进——这点要在 U-verify 用真 RAGFlow 验证副本最终可检索(§U-verify 场景 1)。
**Test scenarios(输入+预期)**:
- fork 公开副本|输入:发布者 kb=K(含 3 个 searchable 公开文档 + 1 个上架后新增的私有文档 P),assetId=X 上架触发 fork|预期:新建副本 dataset D_pub;D_pub 含 K 上架时刻的 3 个公开文档(重索引后可检索),**不含**私有文档 P;`forkStatus=ready` + 副本 datasetId 可被 `MarketAssetSourceApi` 带回。
- whole-KB 公开集界定|输入:上架时刻 K 有文档 {d1 searchable, d2 generative, d3 scan_blocked, d4 deleting}|预期:副本只复制 {d1, d2}(可检索的),跳过 d3/d4。
- 幂等-重复 fork|输入:同一 (assetId=X, version=1) 再次触发 fork(重新派发事件/重试)|预期:**不新建第二个副本 dataset**(命中 ready 跳过);已 ready 副本不变。
- 部分成功重试|输入:首次 fork 时 d2 上传失败(RAGFlow 502)→ `forkStatus=partial`;重试再来|预期:副本 dataset 不重建,d1 不重复上传,只补 d2;补成功后 `forkStatus=ready`。
- 失败不阻断审核|输入:fork 全程 RAGFlow 不可用|预期:market 审核已提交 listed(事件已落),`forkStatus=failed`+可重试;安装侧据 `forkStatus≠ready` fail-closed(不会让安装者绑到不存在的副本)。
**Verification**:
- 模块单测/IT:`cd muse-cloud && mvn -pl muse-module-knowledge/muse-module-knowledge-server -am test -Dtest='KnowledgeMarketForkServiceTest'`(mock `RagFlowKnowledgeRuntimeClient`,覆盖公开集界定 + 幂等 + 部分成功重试 + 私有文档不进副本)。
- 活体(真 RAGFlow,mini-infra 100.64.0.8 在线):按模块 `.agent`/`.agents/knowledge` 起栈,真 fork 一份副本并对副本 `retrieveChunks` 确认可检索 + 不含私有 magic(并入 U-verify 端到端)。
- **凭据红线**:RAGFlow base-url/api-key 由 env 注入,脚本/文档不打印任何 key 值(过滤 `password|secret|token|sk-`)。
---
### U-materialize · 安装侧物化(原 U2 简化,去隔离)
**Goal**:在 knowledge 目标域绑定路径里,把"只写引用绑定"升级为"建出可用的本地 installed_ref 知识库实体 + binding 指向**公开副本 dataset**(非发布者活 dataset)";做到 kb_id 去污染(assetId→本地主键)+ 新增 `MarketAssetSourceApi` 跨 BC 读(带回公开副本 dataset id + forkStatus)+ 幂等 + kbId 存在性校验。**相对临时-03 U2 的简化**:去掉给文档打安装维度元数据、去掉运行时隔离前置(D0-fork 不需要)。
**Files**:
- 主改:`.../knowledge/application/muse/MuseKnowledgeBindingService.java`(`createKnowledgeBindingPrecheck:71-127`、`createKnowledgeBinding:129-177`、`consumeMarketHandoff:273-284`)
- 新增跨 BC 读端口(事实 4):
- market 侧 API(被 knowledge 依赖):`muse-cloud/muse-module-market/muse-module-market-api/src/main/java/cn/iocoder/muse/module/market/api/asset/MarketAssetSourceApi.java`(新)+ DTO `.../api/asset/dto/MarketAssetSourceRespDTO.java`(新,含 `sourceType/sourceId=发布者kbId/publisherUserId/publicForkDatasetId/forkStatus`)
- market 侧实现:`muse-cloud/muse-module-market/muse-module-market-server/src/main/java/cn/iocoder/muse/module/market/api/asset/MarketAssetSourceApiImpl.java`(新,读 `muse_market_asset` 的 source_id 等 + 副本 dataset 存放,**经 market 自己的 .dal**)
- installed_ref kb 写入 DAL:`.../knowledge/dal/mysql/muse/MuseKnowledgeBaseMapper.java`(复用 insert)、`MuseKnowledgeRagflowBindingMapper.java`(复用 insert)
- 参照范本:`.../knowledge/application/muse/MuseKnowledgeDocumentService.java`(`persistDatasetBinding:321-347` 数据集级行写法 + `DataIntegrityViolationException` 回读幂等)
**Approach**:
1. **新增 market 跨 BC 读端口**(事实 4):`MarketAssetSourceApi.getAssetSource(Long assetId)` 返 `{sourceType, sourceId=发布者kbId, publisherUserId, publicForkDatasetId, forkStatus}`。范式同 `MarketHandoffTokenApi`(本地 Bean,knowledge 注入它而非读 market.dal)。**与临时-03 的关键区别**:临时-03 带回"发布者活 dataset"(要 knowledge 跨 owner 读发布者 kb 的 dataset,有租户拦截器难题 D4),**D0-fork 带回的是"公开副本 dataset id"**——副本是 fork 时新建的、其 dataset id 已由 U-fork 留存在 market 可读的存放(§6),market 直接读自有数据即可带回,**不再需要 knowledge 跨 owner 读发布者 dataset,临时-03 的 D4 难题在 D0-fork 下消失**。保证不破 ArchUnit `bc-boundaries`。
2. **kbId 跨 BC 存在性校验 + 去污染**:market_kb 绑定时 `sourceId` 当作 **assetId** 处理(不再 `parseLong` 当 kbId):调 `MarketAssetSourceApi.getAssetSource(assetId)` 校验资产存在且为 `knowledge_base` 类型且 `forkStatus=ready`(不存在/类型不符/副本未就绪 → fail-closed 抛错,替代现状裸信客户端)。
3. **建本地 installed_ref 实体(落点:`createKnowledgeBinding` 同一 `@Transactional` 事务内)**:
- 写 `muse_knowledge_binding` 之前先 insert `muse_knowledge_base`:`kb_type='installed_ref'`、`source_market_asset_id=assetId`、`license_snapshot_id=授权快照主键`、`owner_user_id=安装者`、`status='active'`、`active_version=1`、`name/description` 取资产摘要(`MarketAssetSourceApi` 可一并带回或占位);拿到**新建本地 kbId**。
- insert `muse_knowledge_ragflow_binding`:`kb_id=新建本地kbId`、`ragflow_dataset_id=公开副本datasetId`、`document_id=null`、`status='active'`、`active_version=1`,形态仿 `persistDatasetBinding`。**副本是共享只读的,多个安装者的 installed_ref binding 都指向同一 publicForkDatasetId**(副本仅 1 份),但各自 kb_id 是各安装者本地新建主键,不撞 `uk_..._active`。
- **去污染**:`binding.setKbId(新建本地kbId)`(不再是 assetId);建实体放 `createKnowledgeBinding`(用户确认绑定的写事实点),precheck 仅校验资产存在性 + forkStatus,避免 precheck 失败留孤儿 kb 行。
4. **fork 未就绪的 fail-closed(决策点 D6)**:若 `getAssetSource` 返 `forkStatus≠ready`(fork 中/失败),有两种处置:(a) **绑定阶段直接拒**(抛"来源副本准备中"错误码,让用户稍后重试);(b) **允许建 installed_ref kb 但 binding 指向空/检索 no_dataset**(绑定成功、检索暂不命中,副本就绪后自动可用)。**倾向 (a)**(fail-closed 更清晰,避免"绑了但用不了"的迷惑态);(b) 需 U-retrieve 容忍 no_dataset(本就容忍)。U-materialize 实现时定夺并回填。
5. **幂等**(重复 install/bind 不重复造实体):建 installed_ref kb 行前先 `SELECT ... WHERE source_market_asset_id=assetId AND owner_user_id=安装者 AND deleted=false`,命中则复用既有 installed_ref kbId,不重复建;dataset binding 复用 `persistDatasetBinding` 的 `DataIntegrityViolationException`→回读模式应对并发。
6. **事务原子性**:物化与"消费 handoff token + 写绑定事实"在同一事务(现状 `consumeMarketHandoff` 已沿用调用方 `@Transactional`),失败整体回滚。
**Test scenarios(输入+预期)**:
- 首次安装绑定(副本就绪)|输入:T_A 安装 assetId=X(knowledge_base 类型,公开副本 D_pub,forkStatus=ready),`createKnowledgeBinding`(合法 handoff token + precheckId)|预期:新建 `muse_knowledge_base(kb_type=installed_ref, source_market_asset_id=X, owner_user_id=A)` 记 id=K2;`muse_knowledge_binding.kb_id=K2`(**非 X**);新建 `muse_knowledge_ragflow_binding(kb_id=K2, ragflow_dataset_id=D_pub, document_id=null, status=active)`。
- 多安装者共享副本|输入:A、B 各装 assetId=X|预期:建两行 installed_ref kb(K2、K3,各自 owner),两条 ragflow_binding **都指向同一 D_pub**(副本仅 1 份),不违反 `uk_..._active`(kb_id 各异)。
- 幂等-重复绑定|输入:同一 (A, X) 再次 install+bind|预期:不新建第二行 installed_ref kb(复用 K2),不抛 500。
- 副本未就绪 fail-closed|输入:assetId=X 的 `forkStatus=partial/failed`|预期:按 D6 处置(倾向拒绝绑定,0 写 installed_ref/binding,抛明确错误码)。
- 资产不存在/类型不符|输入:`sourceId` 指向不存在 assetId 或 work 类型|预期:fail-closed 拒绝,0 写。
- BC 边界|输入:ArchUnit `BcBoundaryArchTest`|预期:knowledge 不直接 import market 的 `.dal`(只经 `MarketAssetSourceApi`),test 绿。
**Verification**:
- 模块单测/IT:`cd muse-cloud && mvn -pl muse-module-knowledge/muse-module-knowledge-server -am test -Dtest='MuseKnowledgeBindingServiceTest,*RoundTripTest'`。
- **改了 market-api 模块务必先 `mvn -pl muse-module-market/muse-module-market-api -am install`** 再跑 knowledge 模块测试,否则 stale jar 假红(memory `muse-market-install-isacquired-enrich` 教训)。
- ArchUnit:`mvn -pl ... test -Dtest=BcBoundaryArchTest`。
---
### U-retrieve · 检索打通(原 U3 简化,无运行时隔离)
**Goal**:物化后,检索第四门 `selectActiveDatasetByKbId(本地kbId)` 返非空 → 命中(消除 `no_dataset` 静默省略);**因检索命中的是纯公开副本,无需任何运行时 chunk 隔离**;授权快照沿用 ADR-020 VARCHAR envelope,不回退 BIGINT/parseLong。**相对临时-03 U3 的简化**:删掉 metadata_filter/document_ids 隔离透传(D0-fork 不需要),`metadata_filter` 字段 bug 不在本单元修(潜伏,§7 R6)。
**Files**:
- 主路径(**无需改**,靠 U-materialize 数据正确即自然打通):`.../knowledge/api/MuseKnowledgeRetrievalApiImpl.java`(第四门 `:122-127`、`parseChunks:171-198`)
- 不改:`RetrieveChunksCommand` 构造(`:148-151`)保持 `document_ids=null, metadata_filter=null` 现状(副本纯公开,整库返回即纯公开内容)
**Approach**:
1. **检索基线打通**:U-materialize 把 `binding.kb_id` 改成本地真实 kbId、并建好数据集级 `muse_knowledge_ragflow_binding(kb_id=本地kbId, ragflow_dataset_id=公开副本)` 后,检索链第四门 `selectActiveDatasetByKbId(本地kbId)` 自然返非空 → `no_dataset` 消除 → 进 RAGFlow 检索副本。**RetrievalApiImpl 代码无需改动**。
2. **无运行时隔离(D0-fork 核心简化)**:检索命中的是公开副本 dataset,其"整库返回"返回的本就只有公开内容(发布者私有物理不在副本内,事实 1 的"整库返回"在这里是安全的)。因此**不需要** metadata_filter/document_ids 把 chunk 限到子集——这正是 D0-fork 比临时-03 共享分支简单的地方。
3. **授权快照沿用 ADR-020**:检索门读 `binding.getAuthorizationSnapshotId()`(V14 已 VARCHAR),installed_ref binding 写入时沿用 VARCHAR 字符串承载;chunk §5.3 合同字段 `authorizationSnapshot` 透传字符串,不 parseLong。
**Test scenarios(输入+预期)**:
- no_dataset 消除 + 命中|输入:U-materialize 物化后的 installed_ref(本地kbId=K2,关联公开副本 D_pub),work 绑定含 search 用途 + 授权快照,`retrieveForWork(question 命中 D_pub 公开内容)`|预期:结果**非** `empty("no_dataset")`,进入 RAGFlow 检索,`RetrievalResult.ok(chunks)`,chunk 带齐 §5.3 字段;对照现状(同输入必返 `no_dataset`)。
- 发布者私有不泄露(承 U0 反向基线,核心)|输入:发布者活 dataset 含私有 magic chunk(上架后新增、未进副本),安装者 T_A 检索其 installed_ref(指向 D_pub)|预期:返回 chunk **不含**发布者私有 magic(因 D_pub 物理不含该文档),证明 D0-fork 隔离成立。
- 授权快照字符串|输入:installed_ref binding 的 `authorization_snapshot_id` 为字符串 envelope 形态|预期:检索门正常放行、chunk 透传字符串,无 NumberFormatException、不落 null。
**Verification**:
- 单测:`mvn -pl muse-module-knowledge/muse-module-knowledge-server -am test -Dtest=MuseKnowledgeRetrievalApiImplTest`(mock RAGFlow,覆盖 no_dataset 消除 + 字符串授权快照;私有不泄露并入 U-verify 真 RAGFlow)。
- 活体并入 U-verify 端到端。
---
### U-verify · 验证(真 PG 端到端 + studio e2e + 发布者私有不泄露 + 回滚)
**Goal**:用真实证据证明"知识库上架→fork 公开副本→安装→绑定→物化→检索命中"端到端可真用;且**发布者私有内容不泄露给安装者**(D0-fork 的核心安全目标);召回/下架时已物化实体能回滚。
**Files**:
- 真 PG IT(新):`muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/.../P1rMarketKbForkMaterializationIT.java`(参照既有 `P1r*IT` + `P1rRagFlowLiveAcceptanceIT` live 范式)
- studio e2e(新):`muse-studio/e2e/market-install-kb-retrieval.spec.ts`(参照 `knowledge-bindings.spec.ts` + `market-install.spec.ts`)
- U0 反向基线(复用,不重写):`.../knowledge/api/MuseKnowledgeRetrievalApiImplTest.java`(临时-03 已落的 2 个 spike case:混入他源 magic chunk 原样返回 + documentIds/metadataFilter 均 null = 整库扫描)
- 回滚相关(据真实机制,事实 7):`.../knowledge/application/muse/MuseInstalledKnowledgeBaseService.java`(`disableInstalledKnowledgeBase:64`/`deleteInstalledKnowledgeBase:72` 路径)+ knowledge source event/projection
**Approach / Test scenarios(输入+预期)**:
1. **fork→安装→检索端到端(真 PG + 真 RAGFlow)**|输入:发布者建 KB(3 公开文档)→ 上架触发 fork 出公开副本 → 安装市场 KB 资产 → 作品绑定(物化 installed_ref + binding 指向副本)→ `retrieveForWork`|预期:检索第四门拿到公开副本 dataset → RAGFlow chunk 真命中 → 生成上下文含该来源 grounding;对照现状(必 `no_dataset`)。
2. **发布者私有不泄露(D0-fork 核心安全验证,复用 U0 反向基线)**|输入:发布者上架后往活 dataset 加一段含唯一 magic token 的私有 chunk(未进副本)→ 安装者检索其 installed_ref(指向副本)|预期:返回**不含**该 magic chunk(副本物理不含)。**对照 U0 spike 反向基线**:U0 钉死了"指向同一 dataset 时 magic chunk 原样返回",本场景证明"D0-fork 指向副本后 magic chunk 消失"——隔离从"原样泄露"转为"物理不可见"即验收通过。
3. **幂等**|输入:重复触发 fork(同 assetId+version)+ 重复 install/bind|预期:副本 dataset 不重建、installed_ref kb 不重复,DB 中各仅一行。
4. **多安装者共享一份副本**|输入:A、B 各装同一资产|预期:两个 installed_ref kb 指向同一副本 dataset(副本仅 1 份),各自检索命中、互不影响。
5. **回滚/来源传播(基于真实机制,事实 7,不引用不存在的 `muse_source_propagation_target`)**|输入:发布者资产被 admin 召回/下架(`recallAsset`/`delistAsset`)|预期:已物化 installed_ref 实体被阻断新使用(不自动删,符合 `架构-02` 来源事件"不删除正式知识"语义)。**开放项**:market 召回当前对目标 owner 传播是 fail-closed blocked 记录式(事实 7,`writeSourceStatusBlocked` errorCode=TARGET_OWNER_UNAVAILABLE),knowledge 侧消费"召回→停用 installed_ref + 副本"的入口**本期缺**——U-verify 须如实验证现状传播是否触达 installed_ref;若不触达,标为开放项("召回回滚未自动化,需新增 market→knowledge 传播消费者或手动停用"),不假装已闭环。
6. **删除/停用已物化实体**|输入:用户 `disableInstalledKnowledgeBase`/`deleteInstalledKnowledgeBase`|预期:installed_ref 软删(现状 `updateInstalledStatus`),检索读回排除;**不删公开副本 dataset**(副本是共享的,单个安装者停用不影响其他安装者)——副本生命周期绑资产下架/无安装清理(§7 R5,非本期)。
7. **机械门禁**|输入:ArchUnit `BcBoundaryArchTest` + 覆盖 JSON 门|预期:fork 经 knowledge 自有 + 上架事件消费;安装物化只经 knowledge facade + `MarketAssetSourceApi`,market 不直写 knowledge `.dal`,test 绿。
**Verification**:
- 真 PG IT:从 `muse-cloud/` 跑,按 memory `muse-p1r-it-run-recipe` 配 `p1r.flyway.*` argLine + 密码经 env(`~/.config/muse-repo/infra.env`,`set -a && . it && set +a`),online(RAGFlow/New-API 在 mini-infra 在线)。**`_test` 库铁律:IT 的 flyway 目标库绝不指 muse_slice_live。**
- studio e2e:起全栈 app(PG 宿主 100.64.0.8 在线时),`npx playwright test market-install-kb-retrieval.spec.ts`。
- 凭据红线:所有脚本/日志过滤 `password|secret|token|sk-`;不 rm `dump.rdb`/`lefthook`。
---
## 4. 决策点(待人类拍板,按优先级)
| # | 决策点 | 选项 | 倾向 | 阻塞谁 |
|---|---|---|---|---|
| **D4** | **fork 时机** | 上架 `markListed` 时 fork / 首次安装懒 fork | **上架时 fork**(版本快照语义干净 + 安装侧轻 + 浪费可控;懒 fork 破坏快照语义 + 首装延迟 + 并发抢锁) | U-fork 全部 |
| **D5** | **打破 market→knowledge 零依赖的接线** | 事件驱动(market 落上架事件→knowledge 异步消费 fork)/ market 调 knowledge facade 同步 fork | **事件驱动**(审核不被 fork 成败绑架 + 契合 ADR-017 + 保 BC 边界;facade 同步会让审核挂在 RAGFlow 上) | U-fork 接线 |
| D6 | fork 未就绪时安装侧处置 | 绑定阶段拒(fail-closed)/ 允许绑定但检索 no_dataset 待就绪 | **绑定阶段拒**(清晰,避免"绑了用不了"迷惑态);U-materialize 实现时定夺回填 | U-materialize |
| D7 | 副本更新策略(发布者改版后) | 重新上架才更新副本(版本快照语义)/ 不更新 / 实时跟随 | **重新上架才更新**(与"市场资产 = 发布快照"模型一致;实时跟随会重新引入私有泄露风险) | U-fork |
| D8 | `license_snapshot_id` 承载 | 数值主键(不 widen)/ 字符串 envelope(需 V32) | **数值主键**(与 install 侧一致,避免牵动 market 两列 BIGINT 独立议题) | 是否需 V32 |
| D9 | 召回/下架回滚自动化 | 复用现有 market→目标 owner 传播 / 本期标开放项手动停用 | 先验现状传播是否触达 installed_ref,**不假装已有 `muse_source_propagation_target`**;倾向本期标开放项 | U-verify |
| D10 | 副本 dataset id + fork 状态存放(DDL 关键) | 复用 `muse_knowledge_ragflow_binding` 数据集级行 + `muse_market_asset.tags` JSONB(不加 DDL)/ 新增列/表(需 V32) | **复用现有结构不加 DDL**(见 §6);新增列仅在复用不够时触发 | 是否需 V32 |
---
## 5. fork 时机推荐与接线推荐(返回必答,独立成节便于决策)
- **fork 时机推荐 = 上架 `markListed` 时 fork(非首次安装懒 fork)**。核心理由:副本语义必须是"上架审核那一刻的公开快照"才能成立隔离(事实 5),上架时 fork 让这个语义天然成立、且安装侧无异步延迟;懒 fork 要么把"确定公开集"提前到上架(等于做了一半上架 fork)、要么复制的不再是上架快照(隔离失守),还要扛首装延迟与并发抢锁。RAGFlow 资源浪费是上架时 fork 的唯一代价,可后续用"下架/无安装超时清理副本"缓解。
- **接线推荐 = 事件驱动(market 落上架事件 → knowledge 监听异步 fork),非 market 调 knowledge facade 同步**。核心理由:fork 是异步重活(多次 RAGFlow 调用、可能失败/重试),绝不能塞进审核同步事务把审核绑架在 RAGFlow 上;事件驱动让审核事务只落 market 资产事实 + 发事件即返回,fork 在 knowledge 侧自治重试,契合已拍板 ADR-017,且保住 market 不反向依赖 knowledge 的 BC 边界。代价是需建上架事件投影 + knowledge 消费者,并引入"fork 异步、安装时副本可能未就绪"的中间态(U-materialize 用 `forkStatus` fail-closed 兜住)。
---
## 6. DDL 判定:是否需要新 Flyway 迁移
**判定结论:D0-fork 初版倾向不需要新 Flyway 迁移**(落在 V5/V14 已有列 + 现有 JSONB),但有一个"副本存放落点"的选择会影响这一结论:
- **安装侧物化(U-materialize)**:落在 `muse_knowledge_base`(V5 已备 `kb_type`/`source_market_asset_id`/`license_snapshot_id`)+ `muse_knowledge_ragflow_binding`(V14 已有列),**不需要迁移**(事实 8)。
- **发布侧副本 dataset id + fork 状态存放(决策点 D10,唯一可能触发 DDL 处)**:
- **倾向方案(不加 DDL)**:副本 dataset 本身落一行 `muse_knowledge_ragflow_binding`(这正是该表语义——kb→dataset 映射,副本可挂在发布者 kb 上以"market_public_fork"类型的 binding_summary 标记,或挂在一个专用载体行),`forkStatus` 与副本 datasetId 通过 `binding_summary` JSONB + `muse_market_asset.tags` JSONB(`MarketAssetSourceApi` 从这里带回)承载。**这样不需要新列。**
- **需 V32 的情形**:若评审认为 fork 状态/副本映射必须强类型列(而非 JSONB),则新增 `muse_market_asset` 的 `public_fork_dataset_id VARCHAR + fork_status VARCHAR` 列,或新建 `muse_market_public_fork` 映射表 → 需 V32(DDL 人类门 + `_test` 库铁律 + Flyway 不可逆约定)。
- 另:`license_snapshot_id` 若改存字符串 envelope(D8 选字符串)需 `ALTER ... TYPE VARCHAR` → V32;**倾向数值主键不 widen,不触发**。
**给人类的明确建议**:先按"复用现有结构 + JSONB 承载 fork 状态/副本映射"做,**不引入 V32**;仅当评审坚持强类型列时才走 V32(届时标 DDL 人类门 + V32 版号 + `_test` 库先验)。
---
## 7. 风险
- **R1(最高)发布者私有泄露**:D0-fork 的全部价值在于"副本只含公开快照"。若 fork 的公开集界定错(误把私有文档纳入)、或副本更新策略选"实时跟随活 dataset"(D7)→ 私有重新泄露。**缓解**:U-fork §3.3 严格按"上架时刻 active 文档"界定 + D7 选"重新上架才更新" + U-verify 场景 2 真验私有不泄露(复用 U0 反向基线)。
- **R2 fork 异步重活的复杂度(如实计入,不掩盖)**:fork 一份副本 = 建 dataset + N 文档逐个 upload + N 文档逐个 startParse + 重索引,是多次外部调用的长任务,必须处理失败/重试/幂等/部分成功(U-fork §3.5)。这是 D0-fork 相对临时-03 最大的新增工程量,**比"共享 + 运行时隔离"重**——诚实呈现:D0-fork 安全模型更干净(物理隔离),但发布侧工程更重(异步复制管线)。
- **R3 打破 market→knowledge 零依赖的影响面(如实写清)**:当前 market 对 knowledge **零依赖**(架构既有事实),D0-fork 必须打破它。事件驱动方案下,market 侧改动 = `markListed` 落上架事件(新增 outbox/事件类型),knowledge 侧新增消费者;**影响面集中在"上架审核落事件"这一点 + knowledge 新增 fork 消费链**,不改 market 既有审核/资产逻辑主体。但这是架构层面的新接缝,需架构 review 确认(ADR-017 事件驱动是其依据)。
- **R4 副本就绪与安装的时序竞态**:上架 fork 异步,安装者可能在 `forkStatus=ready` 前就来绑定。**缓解**:U-materialize ④ 用 `forkStatus` fail-closed(D6),副本就绪后再绑/检索命中。
- **R5 副本生命周期/资源回收(本期不做,标开放)**:上架即 fork 会占 RAGFlow dataset 资源,下架/长期无安装的副本清理本期不做。**缓解**:标开放项("下架/无安装超时清理副本 dataset"),非 D0-fork 关键路径。
- **R6 `metadata_filter` 字段 bug(潜伏,单独记,留待修)**:`HttpRagFlowKnowledgeRuntimeClient.retrieveChunks:155` 发的 `metadata_filter` 被 RAGFlow 静默忽略(应为 `metadata_condition`,事实 2)。**D0-fork 不依赖它**(公开副本纯公开),故本期不修、降级为潜伏 bug;但它是真 bug(任何未来想靠元数据过滤检索的功能都会踩),**留待后续单独修**,不随 D0-fork 关键路径处理。
- **R7 召回/下架回滚未自动化(开放项)**:事实 7,本期标开放,不假装闭环(D9)。
---
## 8. 回滚(本 plan 执行后如何撤回)
- **代码回滚**:U-materialize/U-retrieve 改动集中在 `MuseKnowledgeBindingService` + 新增 `MarketAssetSourceApi`(market-api/server);U-fork 改动是新增 `KnowledgeMarketForkService` + 消费者 + market `markListed` 落事件。revert 这些 commit 即回到"只写引用绑定 + market 零依赖 + 不 fork"现状,检索回落 `no_dataset` 静默省略(功能不可用但不报错,与现状一致)。
- **数据回滚**:已物化的 installed_ref kb 行 + binding 经软删停用;**fork 出的公开副本 dataset 可删**(按副本存放落点找到 datasetId,调 RAGFlow 删 dataset;副本是 D0-fork 新建的、删除无副作用于发布者活 dataset)。
- **迁移回滚**:初版不引入 V32(D8 数值主键 + D10 JSONB 承载),无迁移需回滚;若触发 V32 则按 Flyway 不可逆约定,靠新 forward 迁移回滚(且 `_test` 库先验)。
---
## 9. Scope 边界(明确不做什么)
- **KB 限定**:本 plan **只做知识库(KB)的 D0-fork 物化**。
- **agent 下一阶段**:智能体(agent)物化**不在本 plan**——`muse_agent` 的 `market_installed` 落地 + 运行期放行是独立的下一阶段任务,本 plan 不触碰 ai 模块。
- **install 解耦不动**:`MarketInstallServiceImpl.installMarketplaceAsset` 维持"只记账、`targetFactsWritten=false`"现状(`:96`),**不在 install 侧物化**——物化落点严格在 knowledge 目标域绑定路径。
- **handoff 非物化桥**:handoff token 只负责跳转 + 被目标域消费(`MarketHandoffTokenApi.verify/consume` 不动)。
- **运行时 chunk 隔离不做**:D0-fork 用物理隔离替代,临时-03 的 metadata_filter/document_ids 运行时隔离**本期不实现**。
- **`metadata_filter` 字段 bug 本期不修**:降级为潜伏 bug 单记(R6),留待后续。
- **副本资源回收不做**:下架/无安装清理副本 dataset 本期不做(R5),标开放。
- **召回/下架回滚自动化不做**:本期标开放项(R7/D9),不假装闭环。
- **market 两列 BIGINT 议题不纳入**:`muse_market_installation.authorization_snapshot_id`/`source_snapshot_id` 仍 BIGINT 是独立契约议题,本 plan 不处理。
---
## 10. 关联阅读
- 方案演进与权衡(本 plan 的 WHAT 上游):[`临时-02-market-install下游物化方案.md`](临时-02-market-install下游物化方案.md)
- 上一版执行 plan(共享 + 运行时隔离,**已被本稿 supersede**,保留作决策演进史):[`临时-03-market-install-KB物化执行plan.md`](临时-03-market-install-KB物化执行plan.md)
- 概念与 owner 边界:[`架构-02-核心数据结构与双轨模型.md`](架构-02-核心数据结构与双轨模型.md)(§6 市场资产、§11.2 绑定安装授权、授权快照语义)
- 检索合同 / fail-closed:[`专题-03-AI编排上下文与质量评测实现规范.md`](专题-03-AI编排上下文与质量评测实现规范.md)(§5 检索和图查询、§5.3 检索结果合同)
- 关键决策:`架构-03-关键决策与原则(ADR).md`(ADR-017 Source 传播事件驱动、ADR-020 授权快照字符串承载)
- 表结构 SSOT:`后端-04-统一数据库Schema-v1.md`(§5 Knowledge)
- BC 边界规则:[`.agents/rules/bc-boundaries.md`](../.agents/rules/bc-boundaries.md)