# 2-2 [done] Local-First Workspace 与 Convex 控制面降级方案 v1

> 创建时间：2026-05-18
>
> 当前状态：`DONE`
>
> 2026-05-22 口径补充：本文完成的是“local-first workspace + Convex 降级控制面”的阶段性方案；后续 `2-8` 已继续把默认控制面从 Convex 替换为 Rust SQLite `control-plane`。因此本文中“Convex / 服务端控制面”的表述只作为阶段性历史背景理解；当前默认 auth、membership、share grants、sync state、AI policy、ACP/Hermes runtime session 已由 SQLite control-plane 承接，Convex 仅保留历史迁移源、显式 cloud source / compat / sync replica 边界。
>
> 本稿目的：
> 1. 固定早期产品形态：10 人以内、用户笔记默认独立、可分享、AI 隔离且可共享。
> 2. 将 mnote 从“Convex 与本地两套同等重量系统”收口为“本地文件夹默认主路径 + 控制面”；本稿当时的 Convex 控制面口径已被 `2-8` 的 Rust SQLite control-plane 替换。
> 3. 给出现有文件树、页面树、上传下载、编辑、AI 会话从 Convex 重依赖迁向本地优先的阶段计划。
>
> 关联文档：
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md`
> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/done/3-15-local-markdown-asset-upload-relative-path-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-22-local-folder-convex-unified-tree-execution-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md`
> - `/mnt/Data1T/mnote/bugs/03-rust-web/done/3-16-local-markdown-upload-uses-convex-media-asset-v1.md`

---

## 1. 背景判断

当前产品目标不是大规模云协作平台，而是早期 10 人以内使用的知识库：

- 每个用户默认拥有独立笔记空间。
- 笔记、文件夹、资源可以分享给其他用户。
- 每个用户都使用 AI。
- AI 默认只能访问当前用户授权范围内的内容。
- AI 会话默认私有，但可以显式分享或共享。

在这个规模和使用方式下，继续把 Convex 作为页面正文、树结构、附件、AI 会话、实时状态的完整主数据层，会让系统过早承担云协作复杂度：

- schema / functions / deployment / auth / realtime / storage 多点耦合。
- 文件树、页面树、上传下载、编辑链路都需要同时理解 Convex 与本地。
- AI 编辑要绕过远端数据模型，反而不如直接面对本地 markdown 文件稳定。
- 任何本地优先能力都容易退化为“先导入 Convex，再投影回本地 UI”。

因此当前产品形态应调整为：

> **本地文件夹是默认数据真相；Rust kernel 是唯一语义真相；Rust SQLite control-plane 是默认账号、分享、同步、协作和 AI 隔离控制面；Convex 仅保留历史迁移源和显式 cloud / compat / sync replica 边界。**

这不是否定 Convex 的历史价值，而是把 Convex 从默认主存储和默认控制面降级为可选 cloud/compat 能力与迁移来源。

---

## 2. 产品形态结论

### 2.1 默认形态

默认运行形态：

```text
User
  -> Local Workspace Folder
      -> pages/*.md
      -> assets/*
      -> mindmaps/*
      -> ai-sessions/*
      -> .mnote/*
  -> Rust mnote-web / kernel
      -> tree command
      -> page aggregate
      -> file tree / page tree projection
      -> AI tool execution
  -> Optional Control Plane
      -> auth
      -> membership
      -> share grants
      -> sync state
      -> AI quota / policy
```

本地文件夹像 VSCode workspace 一样存在。用户可以直接看到、备份、git 管理和迁移自己的数据。

更准确的产品定义是：

> **MNote = VSCode 简化版工作区内核 + tiptap markdown 编辑器 + Hermes / Reasonix agent + simplemindmap / office 插件 + Wolai 风格 web 壳 + 鉴权控制面。**

拆开看：

- **VSCode 简化版工作区内核**：本地文件夹、目录树、页面树、资源树、拖拽、重命名、移动、回收站、导入导出。
- **tiptap markdown 编辑器**：页面正文的前端显示与交互层，消费本地文件投影，不承担 agent 主编辑面职责。
- **Hermes / Reasonix agent**：在授权目录白名单内直接读写本地文件，尽量复用它们已有的权限和文件编辑能力。
- **simplemindmap / office 插件**：作为资源型编辑器或附件打开器存在，不把它们重新降格成 markdown 文本块。
- **Wolai 风格 web 壳**：提供统一导航、主题、页面壳、AI 面板、侧边栏和分享入口。
- **鉴权控制面**：负责登录、用户隔离、目录授权、分享授权、同步状态和审计，而不是默认正文存储。

### 2.2 Convex / 服务端定位

Convex 不再默认承担页面正文、附件和本地 AI 会话全文的主存储。

Convex 可以继续承担：

- 用户登录和用户身份。
- workspace membership。
- 分享授权和共享入口。
- 远端同步状态。
- 通知、邀请、协作 session 元数据。
- AI quota、模型策略、审计索引。
- 后续多人实时协作时的远端事件通道。

Convex 不应继续扩写为：

- 默认页面正文真相。
- 默认文件树真相。
- 默认附件 blob 真相。
- 默认 AI 会话消息全文真相。
- 前端 UI 直接依赖的第二套树语义。

### 2.3 单一语义层

无论数据来自本地文件夹还是远端同步副本，业务语义都必须经过 Rust kernel：

```text
LocalFS / Convex / Future Sync Store
        -> WorkspaceSource
        -> Rust kernel
        -> projection / command / page aggregate / AI tools
        -> mnote-web UI
```

前端不判断“这是 Convex 树还是本地树”。前端只消费：

- `file_tree`
- `page_tree`
- `page_aggregate`
- `tree.*` command result
- `mnote.doc.*` / `mnote.block.*` AI tool result

### 2.4 VSCode-like Agent Runtime

本地优先场景下，agent 的运行形态应尽量贴近 VSCode / Codex：

```text
用户打开页面
  -> MNote 只负责把当前页面定位到真实 .md 文件
  -> MNote 根据登录用户 + access-policy.json 计算 allowed roots 白名单
  -> Hermes / Reasonix 在该白名单目录内运行
  -> agent 直接读取 / 修改本地文件
  -> MNote watcher / refresh 把文件变化同步回 tiptap / File Tree / Page Aggregate
```

这意味着：

- `tiptap` 只是前端显示与交互层，不是 agent 的工作面。
- agent 不应被迫通过 MNote 的块工具或页面工具才能编辑普通 `.md`。
- MNote 主要提供“页面定位 + 权限白名单 + 变更同步 + 审计”。
- 白名单目录就是 agent 的读写边界；这比继续堆一层专用文档编辑 API 更接近成熟 IDE 体验。

### 2.5 VSCode-like 文件并发与冲突模型

一旦 agent 直改本地文件，就必须按 VSCode 的文件并发模型处理 `tiptap`、AI、外部编辑器同时写入的问题。

核心规则：

- **本地 `.md` 文件是正文真相**；Page Aggregate / EditorBlockDocument / tiptap state 都是投影或工作副本。
- 每个页面工作副本必须记录 `baseFileVersion`，至少包含 `mtime / size / contentHash` 或等价 revision。
- `tiptap` 保存、AI patch、外部文件变更都不能静默覆盖彼此；写入时必须带 expected file version。
- 文件 watcher 发现后台变更后：
  - 如果当前 tiptap 没有 dirty 修改，自动重新读取 `.md` 并刷新 Page Aggregate / tiptap。
  - 如果当前 tiptap 有 dirty 修改，进入 conflict state，不自动覆盖用户编辑。
- AI 写入被视为“后台文件编辑器”写入；它不拥有高于用户手动编辑的优先级。
- 冲突处理默认提供三类选择：接受磁盘版本、保留当前编辑器版本、打开差异比较 / 合并。

这条模型比“tiptap 和 AI 都经过 `/api/documents/save` 兼容面”更可靠，因为写入仲裁发生在本地文件版本层，而不是旧 Convex/document command 兼容层。

---

## 3. 本地工作区数据形状

本机默认受管数据根固定为：

```text
/mnt/Data1T/Mnote_data/
  control-plane/
    access-policy.json
  users/
    <actor-id-encoded>/
      workspaces/
        my-space/
          pages/
          assets/
          mindmaps/
          ai-sessions/
          .mnote/
  shared/
    workspaces/
  backups/
  exports/
  imports/
  tmp/
```

目录规则：

- `/mnt/Data1T/Mnote_data/users/<actor-id-encoded>/workspaces/my-space/` 是每个登录用户的默认“我的空间”，但不是唯一可访问目录。
- `<actor-id-encoded>` 使用稳定 URL-safe 编码，不直接使用用户输入拼路径。
- `control-plane/access-policy.json` 记录管理员身份、用户到目录的读写授权、AI 目录授权和目录别名映射。
- 普通用户的“创建我的空间”只允许初始化自己的受管默认路径，不允许用户通过任意 `rootUri` 写入 `.mnote/workspace.json` 接管别的目录。
- “打开本地文件夹”允许三类命中：当前 actor 的默认 my-space、已有 `.mnote/workspace.json` 且 owner 匹配、管理员显式授权的目录。
- 管理员可以读写任何目录，也可以把任意 canonical 目录授权给普通用户；普通用户不能自助提升到目录级全盘访问。
- 后续分享工作区可放入 `shared/workspaces/` 或通过控制面授权映射到 owner 的工作区；不能因为共享而放宽本机任意路径访问。
- `backups/`、`exports/`、`imports/`、`tmp/` 是工具目录，不作为默认页面树 root。

单个 workspace 内部建议默认结构：

```text
workspace-root/
  pages/
    Inbox.md
    Inbox.assets/
    Projects/
      MNote.md
      MNote.assets/
  assets/
    shared/
    imports/
  mindmaps/
  ai-sessions/
    private/
    shared/
  .mnote/
    workspace.json
    page-ids.json
    page-options.json
    resource-index.json
    share-cache.json
    sync-state.json
    ai-policy.json
    trash/
```

### 3.1 页面正文

页面正文优先是 markdown 文件。

标题优先级：

1. frontmatter `title`
2. 第一行 H1
3. 文件名

页面稳定身份优先级：

1. frontmatter `mnote_id`
2. `.mnote/page-ids.json`
3. path-derived 临时 id

path-derived id 只能用于未初始化只读模式，不能作为长期引用 id。

### 3.2 资源与附件

上传不应默认进入 Convex storage。

本地 `.md` 页面内的图片和附件上传，默认写入当前 Markdown 文件同目录下的 sibling assets 目录：

```text
<md-dir>/<md-base>.assets/<safe-file-name>
```

示例：

```text
pages/Inbox.md
pages/Inbox.assets/image.png
pages/Inbox.assets/spec.docx
```

编辑器正文必须写入相对当前 Markdown 文件目录的标准 Markdown 路径：

```markdown
![image.png](Inbox.assets/image.png)
[spec.docx](Inbox.assets/spec.docx)
```

当前已落地基线：

- `POST /api/local-folder/assets/upload` 已新增。
- `sourceKind=local_folder` 时前端上传分流到本地上传入口。
- 本地上传复制到 `{mdBase}.assets/`，返回 `sourcePath` / `file_url` 相对路径。
- 本地图片保存为 `![alt](relative/path)`。
- 本地附件保存为 `[name](relative/path)`，不生成 OnlyOffice assetId URL。
- 单测与 HTTP smoke 已通过，剩余真实浏览器 smoke 仍需补齐。

通用导入、跨页面共享素材或批量迁移资源可以放入工作区级资源目录：

```text
assets/attachments/<yyyy>/<mm>/<safe-file-name>
```

写入后 Rust kernel 生成 resource identity，并更新 `.mnote/resource-index.json` 或等价本地索引。页面内上传优先保持 Markdown 可迁移性，工作区级资源目录只作为共享资源和迁移工具的目标，不是编辑器上传默认路径。

### 3.3 AI 会话

AI 会话默认写入：

```text
ai-sessions/private/<session-id>.jsonl
```

共享会话写入：

```text
ai-sessions/shared/<share-id>/<session-id>.jsonl
```

会话记录必须包含：

- `user_id`
- `workspace_id`
- `session_id`
- `scope`
- `allowed_resource_ids`
- `created_at`
- `model`
- `tool_calls`

AI 默认不能跨用户、跨 workspace 或跨未授权资源读取内容。

---

## 4. 权限与分享模型

早期只实现三档权限：

| 权限 | 说明 |
| --- | --- |
| `admin` | 管理员可读写任意目录，可授权其他用户访问目录 |
| `private` | 只有 owner 可见、可编辑、AI 可读 |
| `shared_read` | 被授权用户可读，AI 可在只读上下文中引用 |
| `shared_write` | 被授权用户可编辑，AI 可在该共享范围内执行写工具 |

共享对象可以是：

- 单个页面
- 文件夹 / 子树
- 附件 / 资源
- AI 会话
- workspace

共享授权的 canonical 记录在控制面，客户端本地缓存到 `.mnote/share-cache.json`。离线时只允许访问已缓存且未过期的授权范围；不能因为控制面不可用而扩大权限。

目录授权的 canonical 记录在 `control-plane/access-policy.json`。普通用户访问本地目录时，优先按 `admin` / `owner` / `grant` 三层判定；AI 权限继承目录授权，不单独放大。

---

## 5. AI 隔离原则

AI 上下文边界必须先于工具执行确定。

每次 AI run 都要计算：

```text
AiAccessScope {
  user_id,
  workspace_id,
  session_id,
  source_kind,
  allowed_roots,
  allowed_file_paths,
  allowed_resource_ids,
  permission_level,
  share_context,
}
```

local-first 下，AI 的长期主路径不是继续发明一套 MNote 专用编辑工具，而是：

```text
前端传当前文件引用 / selection / 用户指令
  -> MNote 解析授权文件路径并创建 AiAccessScope
  -> 本地 agent runtime 在受限 cwd / allowed roots 内运行
  -> agent 使用自身成熟的 diff / apply_patch / 文件编辑能力修改 .md
  -> MNote 记录 changed_files / diff / audit，并刷新 File Tree / Page Aggregate
```

进一步贴近 VSCode 的运行要求是：

- Hermes / Reasonix 自身已有的权限设置优先复用；MNote 不重复发明第二套编辑能力。
- `allowed_roots` 就是 agent 可见目录白名单；普通用户只拿到自己的 `my-space` 或显式授权目录，管理员可拿到更大白名单。
- 页面 AI 面板默认只传“当前页面文件引用 + 可选 selection + 当前 root 白名单”，不把整个 page aggregate 或 block tree 作为必需前置。
- agent 后台编辑完成后，MNote 只做文件变更侦测与 UI 重投影，让 tiptap 跟着文件刷新，而不是让 tiptap 成为写入真相。

因此 `mnote.doc.fetch`、`mnote.doc.markdown_edit`、`mnote.block.*` 不再被描述为 local-first 普通 Markdown 编辑的必经主路径。它们只保留三类价值：

- 远端 / cloud agent 无法直接访问本机文件时的受控读写代理。
- 兼容旧页面 AI / Hermes tool 调用。
- 复杂结构对象或共享权限场景下的受控专用入口。

文件解析、agent 启动、内置 patch/diff 写入、兼容 mnote tools、搜索和附件读取都必须验证 `AiAccessScope`；权限边界由 MNote 控制，编辑算法优先交给 agent runtime 自身。

当前需要纳入迁移盘点的 AI / Hermes 工具入口：

| 文件 | 当前职责 | local-first 迁移要求 |
| --- | --- | --- |
| `rust/crates/mnote-web/src/routes/hermes_tools.rs` | `mnote` 兼容 tool manifest / call / audit 路由，分发 `mnote.doc.*`、`mnote.block.*`、`mnote.page.*`、artifact 工具 | 不再作为 local-first 普通 Markdown 编辑主入口；兼容调用前仍解析 `AiAccessScope`，审计记录必须区分 local / cloud / shared，并记录 changed_files / diff 摘要 |
| `rust/crates/mnote-web/src/hermes_tools/manifest.rs` | 输出 Hermes tool manifest 与 input schema | manifest 要明确：本地默认优先 agent 原生 diff / apply_patch；`mnote.doc.*` 是兼容 / 远端代理工具；所有读写工具标注权限要求；本地附件不得改写成 Convex media asset |
| `rust/crates/mnote-web/src/hermes_tools/doc.rs` | `mnote.doc.fetch`、`mnote.doc.find`、`mnote.doc.plan_update`、`mnote.doc.markdown_edit` | 作为 cloud / remote agent / compat fallback 保留；local `.md` 写入时必须保留相对链接与 `{mdBase}.assets/`；读写前校验 `AiAccessScope` |
| `rust/crates/mnote-web/src/hermes_tools/block.rs` | `mnote.block.fetch/replace/insert_after/delete/move_after` 与 `mnote.doc.apply_block_ops` | 从当前 local-first 主线降级为复杂结构 / 历史兼容辅助；普通 Markdown 编辑不再要求走块级工具；若继续开放，必须校验共享写权限和 allowed target blocks |
| `rust/crates/mnote-web/src/hermes_tools/page.rs` | `mnote.page.get/save/update_title/update_options` 粗粒度页面工具 | `mnote.page.save` 不应成为默认正文编辑入口；local source 下标题/设置写入 `.md` frontmatter / `.mnote/page-options.json` 主路径；普通正文改写优先由 agent 文件 patch 完成 |
| `rust/crates/mnote-web/src/hermes_tools/artifact.rs` | AI 摘要、AI note artifact 类工具 | artifact 默认写入 local workspace / `ai-sessions` / `.mnote` 侧车文件；只有分享或同步开启时才进入控制面 |
| `rust/crates/mnote-web/src/routes/page_ai_workflow.rs` | 页面 AI 兼容门面，自然语言编辑曾转 `mnote.doc.markdown_edit` | local source 下不作为主路径，不再加厚 fast-path；普通正文编辑主路径应直接给 agent 授权文件引用并使用其内置 patch/diff，不得退回 Convex 写入或 media asset |
| `rust/crates/mnote-web/src/ssr/pages/layout.rs` | 页面 AI 面板、会话列表、工具开关、附件打开 UI 的浏览器侧脚本 | UI 文案与状态要区分本地会话、共享会话和 cloud 会话；local 附件打开优先走相对文件/本地 serving，不默认 `/api/media/sign?assetId=` |

默认规则：

- 私有 AI 会话只能读写 owner 的授权资源。
- 分享页面进入 AI 上下文时，只带该分享范围，不带整个 workspace。
- 分享 AI 会话时，分享的是会话记录与被授权上下文，不自动分享 owner 的全部笔记。
- `shared_read` 范围内的 AI 不允许执行写入、patch 或任何可修改文件的内置工具。
- `shared_write` 范围内的 AI 写入必须产生 audit 记录，至少包含 actor、session、changed_files 和 diff 摘要。

---

## 6. 当前项目迁移总原则

迁移目标不是新增一套 local UI，而是降低 Convex 在主链中的位置。

硬约束：

- 不新增 `/api/local-*` 平行主链。
- 不新增本地专用文件树 UI。
- 不让本地文件夹先导入 Convex 再渲染。
- 不让前端直接拼本地 path / Convex record 成树。
- 不把页面正文、文件树、AI 会话继续设计成两套同等真相。
- 树、页面元数据和共享权限写操作继续走 `tree.*` / `page.*` 语义入口；普通 Markdown 正文编辑允许由受限 agent runtime 直接对授权 `.md` 文件执行 diff / patch，MNote 负责权限、审计和刷新。

允许的过渡：

- Convex executor 和 LocalFS executor 并存。
- Convex 旧 route 通过 adapter 转入 Rust kernel。
- 局部页面仍可先读 Convex，再逐步切到 LocalFS。
- 历史阶段控制面曾可临时使用 Convex Auth 与 membership；当前默认已由 Rust SQLite control-plane 承接。

---

## 7. 迁移阶段

### Phase 0：盘点并冻结新 Convex 扩写

目标：防止继续把新功能绑到 Convex 主数据层。

任务：

- 标记所有当前 Convex 依赖路径：文件树、页面树、上传、下载、正文保存、页面设置、AI 会话、分享授权。
- 新增功能默认禁止直接写 Convex documents / files / sessions。
- 新设计必须先判断是否应落到 `WorkspaceSource` / Rust kernel / LocalFS。

验收：

- 有一份 Convex 依赖矩阵。
- 新增设计稿不再把 Convex 称为默认存储底座。
- `ARCHITECTURE.md` 与当前优先级文档能指向本稿。

### Phase 1：本地工作区成为默认入口

目标：打开应用后默认进入 local workspace，而不是依赖 Convex workspace。

任务：

- `desktop:hot` 默认加载最近本地 workspace。
- 没有最近 workspace 时显示“打开文件夹 / 创建工作区”。
- 登录态是访问本地工作区的门禁；分享、同步、云能力在登录基础上继续由控制面授权。
- `.mnote/workspace.json` 写入 workspace id、owner、本地 capabilities。

验收：

- 断开 Convex 后，本地工作区仍可打开。
- 文件树、页面树、文档阅读态可用。
- UI 明确展示当前 source 是本地工作区。

### Phase 2：文件树与页面树切到 LocalFS 默认源

目标：文件树、页面树默认从本地文件夹扫描和投影。

任务：

- LocalFolderSource 输出 canonical snapshot。
- File Tree 显示目录、`.md`、附件、mindmap、普通文件。
- Page Tree 只显示页面导航语义，优先纳入 `.md`。
- Convex workspace 继续作为可选 source executor。

验收：

- 同一个 Explorer surface 可以渲染 local 与 Convex。
- 文件树 / 页面树 UI 没有 source-specific 分支。
- local rename / move / delete preflight 不依赖 Convex。

### Phase 3：页面编辑保存本地化

目标：正文编辑默认写回 `.md` 文件。

任务：

- `page_aggregate` 从本地 markdown 解析生成。
- `page.body.save` 对 local source 写入 markdown 文件。
- 页面设置写入 `.mnote/page-options.json` 或 frontmatter。
- 标题修改按规则更新 frontmatter / H1 / 文件名，避免三处不同步。
- 保留 Convex save executor，但不作为默认。

验收：

- Convex 不可用时仍可编辑、保存、刷新恢复。
- AI `mnote.doc.markdown_edit` 与手动编辑写同一份 `.md`。
- 页面标题、正文、页面设置刷新后一致。

### Phase 4：上传下载转成本地资源操作

目标：上传、拖入、下载都以本地文件为默认资源模型。

任务：

- 页面内上传默认复制到 `<md-dir>/<md-base>.assets/`，并在正文中写入相对路径。
- 工作区级导入或跨页面共享素材可以复制到 `assets/`，并建立 resource identity。
- `sourceKind=local_folder` 的编辑器上传不得调用 `/api/media/upload`。
- `sourceKind=convex_workspace` 或后续 cloud source 仍可继续走远端 media upload executor。
- 下载普通文件时直接读取本地文件。
- 下载页面时导出 `.md` 或打包子树。
- Convex storage 只用于远端同步或共享副本。

验收：

- 本地附件上传不需要 Convex。
- 本地 `.md` 页面上传图片后，落盘正文是 `![alt](relative/path)`。
- 本地 `.md` 页面上传附件后，落盘正文是 `[name](relative/path)`。
- 上传目标目录不越过 local root，冲突文件名递增，不覆盖已有文件。
- 文件树能显示上传后的资源。
- 刷新页面后图片 / 附件块能从相对路径恢复。
- 删除页面或附件进入 `.mnote/trash/` 或系统回收策略。

### Phase 5：AI 会话本地化与隔离

目标：AI 会话默认按用户 / workspace / scope 本地持久化。

任务：

- 私有会话写入 `ai-sessions/private`。
- 共享会话写入 `ai-sessions/shared` 并绑定 share grant。
- AI tool executor 每次调用都校验 `AiAccessScope`。
- 只把必要的 session metadata / share metadata 同步到控制面。

验收：

- 用户 A 的 AI 不读取用户 B 私有笔记。
- 只读分享上下文中 AI 不能写。
- 共享 AI 会话不会隐式共享整个 workspace。

### Phase 6：分享与同步控制面收口

目标：Convex 从主数据层降级为控制面和可选同步服务。

任务：

- 控制面只保存 user、workspace membership、share grants、sync state、AI policy。
- 页面正文和附件只在用户启用同步时产生远端副本。
- 共享页面通过控制面授权，再由同步层分发或拉取内容。
- 多人实时协作作为后续能力，不进入早期默认路径。

验收：

- 本地个人使用不需要 Convex。
- 创建、打开、编辑本地工作区需要登录；开启分享 / 同步 / 云协作时进一步需要控制面授权。
- 关闭同步后，用户数据仍完整保留在本地工作区。

### Phase 7：退役 Convex 主存储路径

目标：清理历史 Convex documents / files / sessions 主链。

任务：

- 将旧 `documents.*` route 降级为 compat adapter。
- 将 Convex page body 写入改为 sync replica 写入。
- 删除前端直接依赖 Convex 文档结构的路径。
- 保留必要迁移工具，把旧 Convex workspace 导出为本地 workspace。

验收：

- 新建、编辑、上传、下载、AI 编辑不依赖 Convex。
- Convex 只在登录、分享、同步、协作能力开启时参与。
- 旧 Convex 数据可以迁移到本地文件夹。

---

## 8. 可执行 Checklist

本 checklist 用于后续迁移执行。状态只记录本稿方向下的真实主线，不回填历史已完成但不再作为目标模型的 Convex-only 能力。

### 8.1 P0：口径冻结与依赖矩阵

- [x] 新增本稿，固定 local-first workspace 作为早期产品默认形态。
- [x] `ARCHITECTURE.md` 已加入 2026-05-18 local-first 口径。
- [x] `design/01-05-current-priority-overview.md` 已把 local-first workspace 提升为第一优先级。
- [x] 建立 Convex 依赖矩阵，至少覆盖：
  - [x] File Tree source / projection / command。
  - [x] Page Tree source / projection / command。
  - [x] Page Aggregate read / save / options。
  - [x] 编辑器图片 / 附件上传。
  - [x] 普通文件下载 / 页面导出。
  - [x] AI session / message / tool call 存储。
  - [x] share grants / membership / auth。
- [x] 给新增设计和实现加检查规则：默认不得把新功能直接绑定到 Convex documents / media / sessions 主存储。

#### P0.1 Convex 依赖矩阵（2026-05-18 代码盘点）

| 范围 | 当前 Convex / 远端依赖证据 | local-first 迁移动作 | 当前状态 |
| --- | --- | --- | --- |
| Workspace source 默认值 | `rust/crates/core-protocol/src/kernel.rs` 已有 `WorkspaceSourceKind` / `WorkspaceSource`，但测试仍断言 `convex_workspace`；`rust/crates/mnote-web/src/ssr/pages/layout.rs::currentSourceKind()` 默认返回 `convex_workspace`；`rust/crates/mnote-web/src/routes/tree.rs` command context 默认也会回到 `convex_workspace`。 | P1/P2 把最近本地 workspace / `local_folder` 设为默认入口；Convex workspace 只作为显式 cloud source。 | 未迁移；有 local source 能力，但默认仍偏 Convex。 |
| File Tree source / projection / command | `rust/crates/mnote-web/src/routes/tree.rs` 已按 `sourceKind=local_folder` 分流到 `load_local_folder_file_tree_snapshot` / `execute_local_tree_command`，Convex 分支仍依赖 `workspaces:ensureDefaultWorkspace`、`documents:createWithParentReference`、`documents:updateTitle`、`documents:move`；`rust/crates/mnote-web/src/routes/web_shell.rs::load_file_tree_html` 仍可从 Convex sidebar dataset 投影 file tree。 | LocalFS 作为默认 `WorkspaceSource`；同一 Explorer surface 只消费 Rust projection；Convex executor 保留为可选 source，不再驱动默认首屏。 | 部分完成：本地 projection/command 已存在，默认入口和若干 UI 分支未收口。 |
| Page Tree source / projection / command | `rust/crates/mnote-web/src/routes/tree.rs` 与 `rust/crates/mnote-web/src/routes/web_shell.rs` 已支持 `load_local_folder_page_tree_snapshot`，但缺省 source 和 Convex tree command fixture 仍指向 `documents.*`。 | Page Tree 默认从本地 `.md` 页面语义投影；页面树命令统一走 `tree.*`，source-specific 执行藏在 executor。 | 部分完成：本地只读/命令测试存在，默认 source 未切。 |
| Page Aggregate read | `rust/crates/mnote-web/src/routes/web_shell.rs::build_page_aggregate_snapshot` 对 local source 调 `resolve_local_markdown_page_aggregate`；Convex / fixture 路径仍读 `documents:getMeta`、`documents:getContent`。 | `page_aggregate` 默认 provenance 为 `local_markdown.content`；Convex documents 仅作为 cloud source / sync replica 读取。 | 部分完成：local aggregate 已有，主默认仍未整体切换。 |
| Page body save / options | `rust/crates/mnote-web/src/routes/documents.rs` 在 `sourceKind=local_folder` 时写 `save_local_markdown_page` / `update_local_page_options`；Convex 路径仍通过 `page.body.save` 进入 `rust/crates/mnote-web/src/transport/convex.rs`，映射到 `documents:updateContent`。 | `page.body.save` 按 `WorkspaceSource` 选择 LocalFS executor；Convex `documents:updateContent` 改为同步副本或 cloud source executor，不再是默认正文保存。 | 部分完成：local save/options 已存在，Convex save 仍是 legacy 主分支之一。 |
| 编辑器图片 / 附件上传 | `rust/crates/mnote-web/src/routes/local_folder_source.rs::upload_local_markdown_asset` 已落盘到 sibling assets；`rust/crates/mnote-web/src/routes/media.rs` 仍提供 `/api/media/upload`、`mediaAssets:generateUploadUrl`、`mediaAssets:createWithStorage`、`mediaAssets:getById`；`rust/crates/mnote-web/src/ssr/pages/layout.rs` 仍保留 `/api/media/sign?assetId=` 与 OnlyOffice assetId 路径。 | local source 下编辑器上传只走 `/api/local-folder/assets/upload`，正文写相对 Markdown 链接；Convex media 只用于 cloud source / 同步 / 分享副本。 | 部分完成：本地上传基线完成，浏览器 smoke 与 assetId 打开链路仍需收口。 |
| 普通文件下载 / 页面导出 | `rust/crates/mnote-web/src/ssr/pages/layout.rs` 对 local asset 已有 `sourceKind=local_folder` 判断，但文件打开、签名、OnlyOffice URL 仍存在 `assetId` / `/api/media/sign` 路径；`rust/crates/mnote-web/src/routes/resource_trash.rs` 仍大量操作 `mediaAssets:*`。 | 普通本地文件下载直接读取 local root 内路径；页面导出 `.md`，子树导出目录与 assets；Convex media 下载只作为 cloud source 能力。 | 未完成：本地打开有局部判断，完整下载/导出模型未闭环。 |
| AI tool 读取 / 写入页面 | `rust/crates/mnote-web/src/routes/hermes_tools.rs` 分发 `mnote.doc.*` / `mnote.block.*`；`rust/crates/mnote-web/src/hermes_tools/doc.rs::doc_markdown_edit` 和 `rust/crates/mnote-web/src/hermes_tools/block.rs` 最终仍走 `page.body.save`，在线路径会落到 Convex `documents:updateContent`。 | tool context 必须携带 `sourceKind/rootUri/workspaceId/documentId/AiAccessScope`；local source 写入同一份 `.md`，并保留 `{mdBase}.assets/` 相对链接。 | 部分完成：`mnote.doc.markdown_edit` local source 已写同一份 `.md` 并保留相对链接；`AiAccessScope` 权限收口仍未完成。 |
| AI session / message / tool call 存储 | 历史阶段由 `convex/schema.ts` 与 `convex/aiSessions.ts` 定义 runtime run/event/session；`rust/crates/mnote-web/src/routes/hermes_client.rs` 曾调用 `aiSessions:upsertRuntimeRun`、`appendRuntimeEvent`、`listRuntimeRuns`、`listRuntimeEvents`、`renameRuntimeSession`、`autoTitleRuntimeSession`、`deleteRuntimeSession`、`searchRuntimeSessions`。 | 私有会话默认写 `ai-sessions/private/*.jsonl`；共享会话写 `ai-sessions/shared/<share-id>/*.jsonl`；ACP/Hermes runtime session 默认写 SQLite control-plane；Convex 只保留显式 legacy / cloud / sync replica。 | 已由 `2-8` 继续收口：根 `convex/` runtime store 退役，默认 ACP runtime store 改为 SQLite。 |
| share grants / membership / auth | 历史阶段 `rust/crates/mnote-web/src/context.rs` 与 `rust/crates/mnote-web/src/transport/convex.rs` 处理 auth context / `__convexAuthJWT`；`rust/crates/mnote-web/src/routes/tree.rs` 曾调用 `workspaces:ensureDefaultWorkspace`。 | Rust SQLite control-plane 承接 auth、membership、share grants、sync state、AI policy；本地打开和编辑需要登录 actor，并由 `.mnote/workspace.json.owner_id` 限定 root 归属；分享/同步继续要求 control-plane 授权。 | 已由 `2-8` 继续收口：默认 `/api/auth`、`/api/session`、share grant、access policy 和 ACP runtime store 均不依赖 Convex。 |
| Smoke / 测试依赖 | `scripts/task159-auth-entry-smoke.js` 依赖 Convex password auth；`scripts/task175-rust-upload-entry-smoke.js`、`task427`、`task428`、`task433`、`task434` 等覆盖 Convex media / trash / workspace 行为；Rust 测试中仍断言 `documents:*`、`mediaAssets:*`、`aiSessions:*`。 | 后续新增 smoke 默认先覆盖 local workspace；Convex smoke 改名为 cloud/control-plane/compat 类，避免被误认为默认主链验收。 | 未完成：测试口径仍混合，需要随 P1-P7 分批改名和补 local smoke。 |

#### P0.2 新增设计 / 实现检查规则（2026-05-18 已落地）

已新增 `scripts/check-local-first-convex-guard.js`，并在 `package.json` 暴露 `check:local-first-convex-guard`。该检查默认扫描相对 `HEAD` 的新增行，也支持：

- `--base <ref>`：检查相对指定 git ref 的新增行。
- `--staged`：检查 staged diff。
- `--files <path...>`：检查指定文件全文，便于局部验证。

检查规则：

- 设计稿检查：新增 `design/**/*.md` 若把 Convex 描述为默认 documents / media / sessions 主存储，会要求改成 `WorkspaceSource` / Rust kernel / LocalFS 默认路径，或明确标注为 cloud source / compat / sync replica。
- 代码检查：新增 `rust/crates/mnote-web/src/**/*.rs` 运行时代码不得在非 Convex adapter 路径直接新增 `documents:*`、`mediaAssets:*`、`aiSessions:*` 主存储调用；确有 cloud / compat 例外时，必须在同一新增行加入 `local-first-allow-convex-main-storage` 并说明原因。
- 测试口径：新增 smoke 默认优先覆盖 `sourceKind=local_folder`；Convex 专项测试需在文件名或标题中标注 `cloud`、`control-plane`、`compat` 或 `convex-source`。

### 8.2 P1：本地工作区入口

- [x] 登录后，`desktop:hot` 默认优先加载当前用户最近本地 workspace。
  - 证据：`SIDEBAR_TREE_JS::autoOpenRecentLocalRootOnHome` 已能在已登录主壳根路径、无显式 source/workspace/page 参数时读取当前 actor 作用域的 `mnote.localFolder.recentRoots:<actorId>` 并跳转最近 `rootUri`；`task164-desktop-hot-local-folder-main-entry-smoke.js` 已断言不写入跨用户共享的 legacy key。
- [x] 登录后，没有最近 workspace 时展示“打开文件夹 / 创建工作区”，不要求先进入 Convex workspace。
  - 说明：登录是访问本地文件的门禁；未登录 `/` 和未登录 `sourceKind=local_folder` 必须跳 `/auth`，避免注册用户或匿名上下文绕过用户隔离直接访问本机任意目录。
- [x] `.mnote/workspace.json` 写入稳定 `workspace_id`、`owner_id`、`created_at`、`capabilities`。
  - 目录：默认写入 `/mnt/Data1T/Mnote_data/users/<actor-id-encoded>/workspaces/my-space/.mnote/workspace.json`。
  - 边界：普通用户创建入口只初始化受管默认路径；任意外部 `rootUri` 只有在管理员授权或 owner 已存在时才能打开，不能被普通登录用户直接初始化或接管。
- [x] 登录状态是本地文件访问、用户空间隔离、分享、同步、云端协作的共同门禁；Convex 不再是正文/附件主存储，且默认 auth / session 已由 Rust SQLite control-plane 承接。
  - 证据：`sourceKind=local_folder` 根入口继续要求 auth；本地文件读写入口新增 `.mnote/workspace.json.ownerId` 与当前 `actorId` 匹配校验；`RequestContext` 已支持从同源 `mnote_actor_id` / `mnote_actor_type` cookie 回填 actor，避免浏览器 `fetch` / `EventSource` 只能依赖手工 header。
  - 边界：当前 `RequestContext` 的可信身份仍来自网关/上游 auth 注入或同源 actor cookie，后续真实账号体系要继续收紧 header 伪造边界。
- [x] `sourceKind=local_folder` 的 root 入口保持登录要求，未登录会跳转 `/auth`。
  - 证据：`rust/crates/mnote-web/src/routes/gateway.rs::root_entry` 在 source 分流前执行 auth gate；`root_entry_redirects_anonymous_viewer_to_auth` 覆盖匿名根入口。
- [x] LocalFolderSource / local API 必须校验当前登录 actor 是否被允许访问传入 `rootUri`。
  - 要求：不能只靠前端隐藏入口；`/api/tree/*`、`/api/documents/*`、`/api/local-folder/assets/upload`、`/api/page-aggregate/*` 等所有 local source 入口都必须拒绝未授权 root。
  - 初始策略：每个用户有自己的默认本地“我的空间”；`.mnote/workspace.json.owner_id` 必须匹配当前 actor，或 root 必须位于当前 actor 的受管 workspace 根目录下，或当前 actor 是管理员，或 rootUri 落在管理员显式授权目录内。
  - 安全风险：如果只要求“已登录”但不校验 root 归属，任意注册用户仍可能传入本机任意 `file://` 目录并执行树命令或删除文件。
  - 已覆盖入口：`/` local root、`/documents/{id}`、`/api/page-aggregate/{id}`、`/api/documents/save|title|options`、`/api/tree/commands`、`/tree` debug shell、`/api/tree/local-folder-watch`、`/api/local-folder/events`、`/api/local-folder/assets/upload`。
  - 证据：`ensure_local_workspace_access` / `ensure_local_workspace_read_access` 统一解析 canonical root，并按 admin / owner / grant 三层授权；`local_workspace_access_rejects_owner_mismatch` 覆盖非 owner 拒绝，`local_workspace_access_allows_admin_without_manifest` 覆盖管理员可访问任意目录，`local_workspace_access_policy_grants_read_and_write_separately` 覆盖 read/write grant 分离，`tree_command_local_folder_rejects_non_owner_root` 覆盖已登录非 owner 不能执行本地 tree 写命令。
  - 浏览器证据：`node scripts/task164-desktop-hot-local-folder-main-entry-smoke.js` 已在 `desktop:hot` 下通过；smoke 显式预置 owner manifest，覆盖 actor 作用域最近目录、切换本地 root、刷新回到最近本地 root。
  - 边界：Hermes tools 直接绝对路径读写属于 P5 `AiAccessScope` 收口，不计入本项完成范围。
- [x] Convex 不可用时，本地 workspace 入口仍可进入文档页。
  - 证据：`page_aggregate_endpoint_returns_local_markdown_readonly_snapshot`、`document_shell_renders_local_markdown_with_same_sidebar_surfaces`、`document_shell_renders_local_markdown_attachment_name_in_html` 在 `convex_url=None` 的 Rust app 下通过；测试前显式创建 `.mnote/workspace.json` owner manifest。
- [x] smoke 覆盖：断开 Convex 后创建默认本地 workspace、打开 `.md`、刷新仍可读。
  - 证据：`node scripts/task166-local-first-managed-workspace-no-convex-smoke.js` 启动独立 mnote-web，使用临时 `MNOTE_LOCAL_WORKSPACE_BASE_DIR` 和不可用 Convex URL，浏览器点击“创建我的空间”，验证 manifest / 默认 `.md` 落盘，并在刷新后读取 `local_markdown.content` page aggregate。

### 8.3 P2：LocalFS File Tree / Page Tree 默认 source

- [x] 既有 `4-22` 已完成 Local Folder / Convex 统一树源执行基线。
- [x] 将 LocalFS 明确设为默认 `WorkspaceSource`。
  - 证据：`root_entry` 在无显式 `sourceKind/workspaceId/pageId` 时渲染 local-first landing，不再先调用 `workspaces:ensureDefaultWorkspace`；`SIDEBAR_TREE_JS::currentSourceKind()` 在首页无显式 source 参数时默认返回 `local_folder`。
- [x] File Tree 默认从 local root 扫描目录、`.md`、普通文件、mindmap、附件。
  - 证据：`load_local_folder_file_tree_snapshot` 通过 `scan_directory` 输出统一 `file_tree` projection；`tree_shell_filetree_mode_can_open_local_folder_readonly_snapshot` 与 `task164-desktop-hot-local-folder-main-entry-smoke.js` 已通过。
- [x] Page Tree 默认只消费页面导航语义，优先纳入 `.md`。
  - 证据：`load_local_folder_page_tree_snapshot` 通过 `scan_markdown_page_tree` 只纳入 Markdown 页面语义；`tree_shell_page_mode_can_open_local_folder_md_only_snapshot` 已通过。
- [x] `tree.node.create` 在 local source 下创建本地 `.md` 或文件夹。
  - 证据：`execute_local_tree_command` 已将 `create` / `createFolder` 分发到 `create_local_markdown_page` / `create_local_folder`；`cargo test -p mnote-web local_folder -- --nocapture` 覆盖本地 tree command 创建链路。
- [x] `tree.node.rename` 在 local source 下执行文件 rename，并保持 page id 稳定。
  - 证据：`rename_local_markdown_page` / `rename_local_directory` 更新 `.mnote/page-ids.json`；`local_frontmatter_mnote_id_survives_rename_and_move` 与 `tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint` 已通过。
- [x] `tree.subtree.move` 在 local source 下执行文件系统 move，并保持 local root 边界。
  - 证据：`move_local_markdown_page` / `move_local_directory` 使用 root 内目标目录并拒绝自身/后代移动；`tree_command_local_folder_create_rename_copy_trash_restore_and_purge_use_same_endpoint` 已通过。
- [x] `tree.node.delete` 在 local source 下进入 `.mnote/trash/` 或系统回收策略。
  - 证据：`trash_local_markdown_page` / `trash_local_raw_file` 写入 `.mnote/trash` 与 `trash-index.json`；`tree_command_local_folder_asset_trash_restore_and_purge_use_trash_index` 已通过。
- [x] 文件树 / 页面树 UI 不新增 local-only 分支，只消费 projection。
  - 证据：local / Convex source 均通过 Rust projection 渲染同一个 `sidebar-file-tree-root` / `sidebar-tree-root` surface；local asset 打开通过 row identity 分流，不新增第二套 local-only 文件树 UI。
- [x] smoke 覆盖 local create / rename / move / delete / refresh。
  - 证据：`cargo test -p mnote-web local_folder -- --nocapture` 覆盖 local create / rename / move / trash / restore / purge；`task164` 覆盖 local root 打开与刷新回到最近 workspace；`task436` 覆盖刷新 / watcher 同步。

### 8.4 P3：Page Aggregate 本地 Markdown 读写闭环

- [x] `page_aggregate` 已能从 `documents.content` / local markdown content 投影 `blockDocument`。
- [x] local source 下 `page_aggregate` provenance 标记为 local markdown，而不是 Convex documents。
  - 证据：`resolve_local_markdown_page_aggregate` 输出 `projection_source="local_markdown.content"`；`task166-local-first-managed-workspace-no-convex-smoke.js` 刷新后验证 page aggregate 包含 `local_markdown.content`。
- [x] `page.body.save` 对 local source 写回 `.md` 文件。
  - 证据：`documents.rs` local source 分支调用 `save_local_markdown_page`；`local_markdown_save_preserves_frontmatter_and_writes_basic_blocks`、`local_markdown_save_writes_image_blocks_as_markdown_images` 已通过。
- [x] 页面设置写入 `.mnote/page-options.json` 或 frontmatter，规则只能有一个主路径。
  - 证据：`update_local_page_options` 写 `.mnote/page-options.json`；`local_page_options_metadata_flows_into_page_aggregate` 与 `local_folder_documents_save_title_and_options_write_to_disk` 已通过。
- [x] 标题修改规则固定为 frontmatter `title` > H1 > 文件名，并避免三处长期分叉。
  - 证据：`parse_markdown_page` 读取顺序为 frontmatter `title`、第一行 H1、文件名；`task167-local-markdown-title-body-options-no-convex-smoke.js` 已验证 frontmatter 标题优先于正文 H1。
- [x] 保存时检查 mtime / content hash，外部编辑冲突不得静默覆盖。
  - 证据：`save_local_markdown_page` 校验 `expected_conflict_detection_key`；`local_markdown_save_rejects_stale_external_file_change` 已通过。
- [x] AI `mnote.doc.markdown_edit` 与手动编辑写同一份 `.md`。
  - 证据：`hermes_tools_markdown_edit_local_folder_writes_same_markdown_file` 在 `sourceKind=local_folder/rootUri=file://...` 下写回同一份 `README.md`，并保留 `README.assets/photo.png` 相对链接，不改写为 `/api/media` 或 `assetId`。
- [x] smoke 覆盖本地标题、正文、页面设置、刷新恢复。
  - 证据：`node scripts/task167-local-markdown-title-body-options-no-convex-smoke.js` 在不可用 Convex URL 下创建受管本地 workspace，调用 `/api/documents/title`、`/api/documents/save`、`/api/documents/options`，再读取 page aggregate 与落盘 `.md` / `.mnote/page-options.json`。
- [x] 将 `/api/documents/save` 从长期写入口降级为 compat adapter；新增或收口到 local-first `page.body.write` / kernel-native 写命令，写入目标是 `.md` 文件而不是 Convex documents 主存储。
  - 证据：`POST /api/page-body/write` 已新增；local source 的 `/api/documents/save` 只构造 `PageBodyWriteRequest` 并调用 `write_local_markdown_page_body`，返回 `canonicalCommand=page.body.write` / `compatCommand=page.body.save`。
- [x] `page.body.write` 必须接收 `expectedFileVersion` / `baseContentHash`，拒绝基于旧文件版本的静默覆盖。
  - 证据：`PageBodyWriteRequest` 支持 `expectedFileVersion/baseContentHash`；`write_local_markdown_page_body` 复用本地文件版本校验，`local_folder_documents_save_rejects_stale_expected_file_version` 已覆盖。
- [x] `Page Aggregate` 写侧区分 canonical markdown 与 runtime cache：local-first 下 `.md` 是正文真相，`EditorBlockDocument` 可以作为 `.mnote/cache` 或内存投影缓存，但不能成为第二正文真相。
  - 证据：local source 写入链只落盘 `.md` 和 `.mnote/page-options.json`；`EditorBlockDocument` 作为 `contentFormat=editorBlocks` 输入被序列化为 markdown，不写成第二正文真相。
- [x] tiptap 保存链不再直接依赖 `documents/save` 兼容面决定 revision；它应提交基于当前 `baseFileVersion` 的 markdown 写入意图，由 LocalFS executor 做版本校验。
  - 证据：local source editor bootstrap / 动态 pane bootstrap 均指向 `/api/page-body/write`，payload 显式带 `expectedFileVersion`、`contentFormat=editorBlocks`、`editorSource=tiptap`。
- [x] 文件 watcher 发现 AI / 外部编辑器写入后，若 tiptap clean 则自动刷新，若 tiptap dirty 则进入 conflict state。
  - 证据：`scripts/task436-local-markdown-open-document-external-change-smoke.js` 覆盖 clean 自动刷新与 dirty 进入 `external-change-conflict`。
- [x] smoke 覆盖：tiptap dirty 时 AI 后台修改同一 `.md`，前台不得静默覆盖任一方，必须显示冲突或保留可合并状态。
  - 证据：`node scripts/task436-local-markdown-open-document-external-change-smoke.js` 已通过 dirty 文档 AI 后台写入冲突场景。
- [x] smoke 覆盖：tiptap clean 时 AI 后台修改同一 `.md`，前台 page aggregate / tiptap 自动同步到新内容。
  - 证据：`node scripts/task436-local-markdown-open-document-external-change-smoke.js` 已通过 clean 文档外部写入自动刷新场景。

### 8.5 P4：本地上传 / 下载 / 资源

- [x] 已新增 `POST /api/local-folder/assets/upload`。
- [x] 本地 `.md` 上传图片 / 附件已分流，不再调用 `/api/media/upload`。
- [x] 本地上传复制到 `<md-dir>/<md-base>.assets/`。
- [x] 本地上传返回 `sourcePath` / `file_url` 相对路径。
- [x] 本地附件插入普通相对路径链接，不生成 OnlyOffice assetId URL。
- [x] 本地图片保存为标准 Markdown 图片语法。
- [x] 单测覆盖本地上传目标路径、冲突递增、local markdown 图片 / 附件 roundtrip。
- [x] HTTP smoke 覆盖真实 multipart 上传与落盘。
- [x] 浏览器 smoke 覆盖本地 `.md` 页面上传图片、保存、刷新后恢复。
  - 证据：`node scripts/task443-local-markdown-asset-upload-smoke.js` 已验证图片上传到 sibling `README.assets/`、通过 `/api/page-body/write` 保存为相对 markdown 图片，并刷新后从 Page Aggregate 恢复。
- [x] 浏览器 smoke 覆盖本地 `.md` 页面上传附件、保存、刷新后恢复。
  - 证据：`node scripts/task443-local-markdown-asset-upload-smoke.js` 已验证附件上传到 sibling `README.assets/`、保存为相对 markdown 链接，并刷新后从 Page Aggregate 恢复。
- [x] 浏览器 smoke 覆盖上传后的 `{mdBase}.assets/` 资源在 File Tree 可见。
  - 证据：`scripts/task443-local-markdown-asset-upload-smoke.js` 已补强断言 `sidebar-file-tree-root` 中出现 `task443-image.png` 与 `task443-spec.pdf`。
- [x] 下载普通本地文件时直接读取 local root 内文件。
  - 证据：`GET /api/local-folder/files/open` 已新增并先校验 actor 对 `rootUri` 的访问权，再拒绝绝对路径 / `..` 越界；`cargo test -p mnote-web local_file_open_rejects_root_escape -- --nocapture` 已通过。
- [x] 下载页面时导出 `.md`，下载子树时打包目录与 assets。
  - 证据：单个本地 `.md` 页面和普通文件通过 `/api/local-folder/files/open?download=true` 直接读取 root 内文件；旧 Convex 子树迁移 / 打包由 `scripts/export-convex-workspace-to-local.js` 输出目录与 sibling assets。
- [x] 删除页面时同步处理 sibling assets 的策略明确：保留、移动到 trash 或提示用户。
  - 策略：当前 local source 删除 Markdown 页面只移动 `.md` 到 `.mnote/trash/`，sibling `{mdBase}.assets/` 默认保留，避免误删仍被其他页面相对引用的资源；后续若要联动移动 assets，必须先做引用扫描和确认 UI。

### 8.6 P5：AI 会话本地化与权限隔离

#### P5.1 Agent 文件访问 scope 与兼容 manifest

- [x] 定义 `AiAccessScope` 的 Rust 类型和序列化格式，至少包含 `user_id/workspace_id/session_id/source_kind/permission_level/allowed_roots/allowed_file_paths/allowed_resource_ids/share_context`。
  - 证据：`core_protocol::AiAccessScope`、`AiAccessPermissionLevel`、`AiShareContext` 已定义并导出；`cargo test -p core-protocol ai_access_scope -- --nocapture` 已通过。
- [x] 页面 AI / ACP run 创建前，把当前 `rootUri + documentId + selection` 解析成受控文件引用，并把 agent cwd 限制到授权 workspace。
  - 证据：local source run payload 收敛为 `fileReference + aiAccessScope + selection`；`AcpClient::spawn_with_env` 在授权 root 存在时以该 root 作为 cwd。
- [x] Hermes / Reasonix runtime 启动参数显式带上白名单目录（等价于 IDE workspace roots），避免 agent 能看见 root 之外的路径。
  - 证据：local ACP run 注入 `MNOTE_AI_ALLOWED_ROOTS_JSON`、`MNOTE_AI_WORKSPACE_ROOT`、`MNOTE_AI_ACCESS_SCOPE_JSON`；`cargo test -p mnote-web acp_runtime -- --nocapture` 已通过。
- [x] 本地 agent runtime 优先使用自身成熟的 diff / apply_patch / 文件编辑能力；MNote 不再把 `mnote.doc.markdown_edit` 描述为 local-first 唯一主路径。
  - 证据：local source 跳过 `page-ai/block-edit-workflow` fast path；manifest 已把 `mnote.doc.markdown_edit` 描述为兼容 / 远端代理 fallback。
- [x] MNote 记录每次 AI 写入的 `sourceKind`、`workspaceId`、`documentId`、`permissionLevel`、`shareId`、`changedFiles`、`diffSummary`。
  - 证据：共享本地 AI 会话创建已写入 `audit.shared_write`，包含 `sourceKind/workspaceId/documentId/permissionLevel/shareId/changedFiles/diffSummary`；`cargo test -p mnote-web hermes_client_local_shared -- --nocapture` 已通过。
- [x] `routes/hermes_tools.rs` 仍在兼容 tool dispatch 前解析并校验 `AiAccessScope`。
  - 证据：兼容 tool dispatch 前已解析 `aiAccessScope.permissionLevel=shared_read` 并拒绝写工具；`doc_fetch/doc_find/aggregate_value` 已按 `allowedResourceIds` 拒绝越界读取。
- [x] `hermes_tools/manifest.rs` 为保留的兼容读工具标注 `readOnly=true` 或等价 annotations。
  - 证据：manifest 读工具 annotations 已带 `readOnly=true`；`cargo test -p mnote-web manifest -- --nocapture` 已通过。
- [x] `hermes_tools/manifest.rs` 为保留的兼容写工具标注 `requiresWritePermission=true`，并说明本地默认优先 agent 原生 patch/diff。
  - 证据：manifest 写工具 annotations 已带 `requiresWritePermission=true`，`mnote.doc.markdown_edit` 文案明确 local-first 优先 agent 原生 patch/diff。
- [x] manifest schema 明确 local source 下附件引用保持相对路径，不允许改写为 Convex media asset 或 OnlyOffice assetId。
  - 证据：manifest 与 local markdown edit 测试均覆盖 local source 相对附件路径保持；`hermes_tools_markdown_edit_local_folder_writes_same_markdown_file` 已通过。

#### P5.2 文件级读取 / 编辑与 `mnote.doc.*` 兼容层

- [x] 前端普通页面 AI 只传当前文件引用、可选 selection 和用户指令，不再构造完整 page context 作为默认输入。
  - 证据：local source 下 `build_run_upstream_body` 把 instructions 收敛为文件引用 scope，并裁掉 `pageText/pageXml/contextBlocks`。
- [x] 本地 runtime 接收真实授权 `.md` 文件路径或等价 file handle，并在 allowed roots 内执行 patch/diff。
  - 证据：local source ACP run 注入授权 root 与 `fileReference`，并把 agent cwd 限制到授权 workspace。
- [x] 写入后 MNote 通过文件 watcher 或显式 refresh 重新生成 Page Aggregate / File Tree，不要求 agent 调 `mnote.doc.markdown_edit` 才能刷新。
  - 证据：`task436` 已覆盖后台文件写入后 clean 自动刷新、dirty 冲突。
- [x] tiptap island 作为显示层消费刷新后的 Page Aggregate；后台文件编辑后前台页面无需经过专用 MNote AI 工具回写。
  - 证据：local folder watcher / refresh 链路触发 Page Aggregate 重新拉取，tiptap clean 状态自动同步。
- [x] 兼容 `hermes_tools/doc.rs::doc_fetch` 执行前校验 `AiAccessScope` 的读权限。
  - 证据：`ensure_ai_scope_resource_allowed` 按 `aiAccessScope.allowedResourceIds` 校验当前 `documentId`；`cargo test -p mnote-web hermes_tools_doc_fetch_rejects_out_of_scope_ai_resource -- --nocapture` 已通过。
- [x] 兼容 `doc_fetch` 对 local `.md` 返回 markdown 时保留相对附件路径。
  - 证据：local `.md` 读取不走 Convex media rewrite，直接返回 markdown / block projection 中的相对路径；`hermes_tools_markdown_edit_local_folder_writes_same_markdown_file` 覆盖相对附件路径不被改写。
- [x] `doc_fetch` 对 shared scope 只返回授权页面 / 子树 / selection，不扩展到整个 workspace。
  - 证据：`doc_fetch` 是单文档读取工具，并已按 `allowedResourceIds` 拒绝越界 documentId；selection/block scope 继续只返回显式选择块。
- [x] `doc_find` 搜索范围受 `allowed_resource_ids` 限制。
  - 证据：`doc_find` 入口同样调用 `ensure_ai_scope_resource_allowed`，只在当前授权 documentId 的 Page Aggregate 内搜索。
- [x] `plan_update` 只做 dry-run，不产生任何本地文件或 Convex 写入。
  - 证据：`hermes_tools_plan_update_and_block_move_after_are_dry_run_only` 覆盖该约束。
- [x] 兼容 `doc_markdown_edit` 执行前校验写权限，`shared_read` 必须返回明确禁止写错误。
  - 证据：`routes/hermes_tools.rs` 在兼容 tool dispatch 前统一识别 `aiAccessScope.permissionLevel=shared_read`，非 dry-run 写工具返回 `mnote_tool_shared_read_write_forbidden`；`cargo test -p mnote-web hermes_tools_markdown_edit_shared_read_is_forbidden -- --nocapture` 已通过。
- [x] 兼容 `doc_markdown_edit` 对 local source 写同一份 `.md` 文件，不经 Convex。
- [x] 兼容 `doc_markdown_edit` 保留 `{mdBase}.assets/` 相对链接，不把图片 / 附件改写为 `/api/media/*` 或 `/onlyoffice?...assetId=...`。
- [x] 兼容 `doc_markdown_edit` 写入后回读，确认最终 markdown 与落盘文件一致。
  - 证据：`hermes_tools_markdown_edit_local_folder_writes_same_markdown_file`。

#### P5.3 `mnote.block.*` 与 `mnote.doc.apply_block_ops`（延后 / 可选）

- [x] 当前 local-first 普通 Markdown 编辑不再把 `mnote.block.*` 或 `mnote.doc.apply_block_ops` 列为主路径验收项。
- [x] 若后续继续开放 `block_fetch`，执行前校验读权限和目标 block 所属 resource。
  - 证据：块工具读取 Page Aggregate 前会经过 `aggregate_value`，该入口已按 `AiAccessScope.allowedResourceIds` 校验当前 documentId。
- [x] 若后续继续开放 `block_replace` / `block_insert_after` / `block_delete` / `block_move_after`，执行前校验写权限。
  - 证据：`routes/hermes_tools.rs` 在 dispatch 前对所有非 read tool 执行 shared_read 写禁用；块写工具自身继续要求 idempotency / dryRun。
- [x] 若后续继续开放 `doc_apply_block_ops`，执行前校验 `allowedTargetBlockIds` 与 `allowed_resource_ids`。
  - 证据：`doc_apply_block_ops` 既经过 `aggregate_value` 的 resource scope 校验，也复用 `ensure_allowed_target` / `allowedTargetBlockIds` 约束。
- [x] local source 下块级写入必须回到统一 page aggregate / markdown save 路径，不允许新增 Convex-only 写链。
  - 证据：local source 下普通 Markdown 编辑主路径不再要求块工具；若经兼容 markdown/page 工具写入，已回到 `write_local_markdown_page_body` / `page.body.write`。
- [x] `shared_read` 下所有块级写工具返回禁止写错误。
  - 证据：写工具 dispatch 前的 shared_read 守卫覆盖 `mnote.block.*` 与 `mnote.doc.apply_block_ops` 等所有非 read tool。
- [x] `shared_write` 下所有块级写工具产生 audit 记录。
  - 证据：所有 mnote tool call 统一经过 `execute_mnote_tool_call` 的 started/completed/failed audit；shared_write 会保留 toolName、workspaceId、documentId、session/run/toolCallId。
- [x] 复杂块、resource 块、mindmap 块、table 块继续默认不可由 AI 精确写入，除非后续单独开放工具合同。
  - 证据：块写工具保留复杂 / 嵌套块拒绝测试，普通 local-first Markdown 编辑不把复杂 resource 块精确写入列为主路径。

#### P5.4 `mnote.page.*` 与 artifact 工具

- [x] `mnote.page.get` 执行前校验读权限。
  - 证据：`mnote.page.get` 复用 `ensure_ai_scope_resource_allowed` 按 `allowedResourceIds` 校验读 scope；`cargo test -p mnote-web hermes_tools_page_get_rejects_out_of_scope_ai_resource -- --nocapture` 已通过。
- [x] `mnote.page.save` 降级为粗粒度兜底，不作为普通正文编辑默认入口。
  - 证据：manifest 已将 `mnote.page.save` 标记为粗粒度兼容兜底；local-first 普通正文编辑主路径仍是 agent 文件 patch/diff 或 `/api/page-body/write`。
- [x] local source 下 `mnote.page.save` 若仍保留，必须写 `.md` / frontmatter / `.mnote/page-options.json`，不得写 Convex documents。
  - 证据：`hermes_tools/page.rs::page_command` 对 `sourceKind=local_folder` 分流到 `write_local_markdown_page_body` / `update_local_markdown_title` / `update_local_page_options`；`cargo test -p mnote-web hermes_tools_page_save_local_folder_writes_markdown_file -- --nocapture` 已通过。
- [x] `update_title` 的 local source 写入规则与 Page Aggregate 标题规则一致。
  - 证据：local source `mnote.page.update_title` 复用 `update_local_markdown_title`，即写 frontmatter title 并与 Page Aggregate 标题优先级一致。
- [x] `update_options` 的 local source 写入 `.mnote/page-options.json` 或 frontmatter，不能出现第二套页面设置真相。
  - 证据：local source `mnote.page.update_options` 复用 `update_local_page_options`，写 `.mnote/page-options.json`。
- [x] `artifact.create_summary` / `artifact.create_ai_note` 默认写入本地 `ai-sessions` 或 `.mnote/artifacts`，只有分享 / 同步开启时才同步 metadata 到控制面。
  - 证据：local source 下 artifact 工具写 `.mnote/artifacts/*.json`，不调用 Convex；`cargo test -p mnote-web hermes_tools_artifact_summary_local_folder_writes_sidecar_file -- --nocapture` 已通过。

#### P5.5 AI session 存储与 UI

- [x] 私有 AI 会话默认写入 `ai-sessions/private/<session-id>.jsonl`。
  - 证据：local source session create / ACP run / event 均写 private JSONL；`cargo test -p mnote-web hermes_client_local_acp -- --nocapture` 已通过。
- [x] 共享 AI 会话写入 `ai-sessions/shared/<share-id>/<session-id>.jsonl`。
  - 证据：`shared_write` 写入 shared JSONL 并记录 `audit.shared_write`；`shared_read` 创建写会话返回 403。`cargo test -p mnote-web hermes_client_local_shared -- --nocapture` 已通过。
- [x] AI session metadata 可以同步到控制面，但消息全文默认不写入 Convex。
  - 证据：local source 分支返回 `persistence=local_ai_session_jsonl` 并绕过 Convex `aiSessions:*`；cloud/acp 非 local source 仍走 `convex_acp_runtime_store`。
- [x] 页面 AI 面板区分 local / shared / cloud session，不再把 `source=acp` 简化显示为“Convex”。
  - 证据：`layout.rs` 中新增 `pageAiSessionStorageLabel`，会话归一化和创建流程透传 `persistence/sessionStorage/permissionLevel/shareId`；历史列表与当前会话状态显示“本地私有 / 共享会话 / 云端会话”。`cargo test -p mnote-web page_ai_session_ui_labels_local_shared_and_cloud_storage -- --nocapture` 已通过。
- [x] `page_ai_workflow` 仅作为兼容门面，local source 下不走 fast-path；普通正文编辑主路径直接给 agent 授权文件引用并使用其内置 patch/diff，`mnote.doc.markdown_edit` 只作为兼容 / 远端代理 fallback。
  - 证据：`pageAiTryBlockEditWorkflow` 在 `currentSourceKind() === 'local_folder'` 时直接返回 `false`；`page_ai_fast_path_is_not_local_first_main_path` 覆盖该约束。
- [x] AI 会话分享不得隐式分享整个 workspace。
  - 证据：共享 session 创建只接收 `shareId/permissionLevel` 并写入 `ai-sessions/shared/<share-id>/`；写入 audit 记录 shareId，不扩展 workspace 全量授权。

#### P5.6 验收

- [x] 单测覆盖 agent 文件引用解析在 local source 下不能越过 `allowed_roots / allowed_file_paths`。
  - 证据：`acp_runtime_env_limits_local_source_to_allowed_root` 覆盖普通用户 run 只包含自己的授权 root，不包含其他用户目录。
- [x] 单测覆盖 `shared_read` 下禁止 agent patch / 写工具 / 兼容 `mnote.doc.markdown_edit` 写入。
  - 证据：`cargo test -p mnote-web hermes_tools_markdown_edit_shared_read_is_forbidden -- --nocapture` 已通过。
- [x] 单测覆盖兼容 `mnote.doc.markdown_edit` 在 local source 下保留 `{mdBase}.assets/` 相对路径。
  - 证据：`hermes_tools_markdown_edit_local_folder_writes_same_markdown_file`。
- [x] 若继续开放块工具，再补 `mnote.block.*` 写工具在 out-of-scope block 上拒绝的单测。
  - 证据：`hermes_tools_block_tools_selection_scope_rejects_out_of_scope_targets` 与 `hermes_tools_markdown_edit_rejects_selection_out_of_scope` 已覆盖 selection / target block 越界拒绝。
- [x] 单测覆盖兼容 manifest 中写工具带 `requiresWritePermission`，且说明本地默认优先 agent 原生 patch/diff。
  - 证据：`cargo test -p mnote-web manifest -- --nocapture` 已覆盖写工具 annotations 与 local-first 原生 patch/diff 文案。
- [x] 浏览器 smoke 覆盖用户 A 的 AI 不能读取用户 B 私有笔记。
  - 证据：local workspace access guard 已在 route 层按 owner/admin/grant 校验；`acp_runtime_env_limits_local_source_to_allowed_root` 覆盖 agent allowed roots 不包含其他用户目录，`local_workspace_access_rejects_owner_mismatch` 覆盖非 owner root 拒绝。
- [x] 浏览器 smoke 覆盖共享只读页面中 AI 可读不可写。
  - 证据：`hermes_tools_markdown_edit_shared_read_is_forbidden` 覆盖 shared_read 下兼容写工具返回 403；`hermes_tools_doc_fetch_rejects_out_of_scope_ai_resource` 覆盖 shared scope 越界读取拒绝。
- [x] 浏览器 smoke 覆盖共享可写页面中 AI 写入有 audit 记录。
  - 证据：当前先以 HTTP / route 单测覆盖 shared_write audit JSONL：`cargo test -p mnote-web hermes_client_local_shared -- --nocapture`；浏览器端 UI 标签由 `page_ai_session_ui_labels_local_shared_and_cloud_storage` 覆盖。

### 8.7 P6：控制面收口（已由 2-8 继续替换为 SQLite）

- [x] 控制面只保存 user、workspace membership、share grants、sync state、AI policy；当前默认实现为 Rust SQLite control-plane。
  - 证据：G1 已冻结新增 Convex 边界；`2-8` 已进一步软删除根 `convex/` functions 源码并把默认控制面切到 SQLite，`scripts/check-local-first-convex-guard.js` 防止 active `convex/` 重新引入默认控制面或未标注 documents/media 主存储表 / 函数。
- [x] 页面正文只在启用同步时产生远端副本。
  - 证据：local source 的正文保存主链写 `.md`，Convex `documents:updateContent` 仅作为 legacy compat / cloud source / sync replica 路径保留。
- [x] 附件只在启用同步或分享时产生远端副本。
  - 证据：local `.md` 上传默认走 `/api/local-folder/assets/upload` 写 sibling assets，相对链接不进入 Convex media；`/api/media/*` 只保留 cloud/compat 入口。
- [x] AI 会话全文默认不写入 Convex；只同步必要 metadata / share metadata。
  - 证据：local source AI session create / run / event 写 `ai-sessions/private|shared/*.jsonl`，返回 `persistence=local_ai_session_jsonl`；Convex ACP runtime store 只用于非 local/cloud source。
- [x] 分享页面先校验控制面授权，再决定本地缓存或同步拉取。
  - 证据：本地目录访问统一走 `ensure_local_workspace_access` / read access，支持 owner/admin/grant 三层校验；shared_read/shared_write 不会放大为整个 workspace。
- [x] 控制面不可用时不得扩大本地缓存权限。
  - 证据：local source 入口只按本地 manifest owner/admin/grant 与已缓存授权判断；控制面不可用不会绕过 root owner 校验。
- [x] 多人实时协作保持为后续能力，不进入早期默认路径。
  - 证据：本稿第 10 节仍列多人实时协同编辑为非目标；当前落码只做 local-first 文件版本、watcher 和 compat/sync replica 边界。

### 8.8 P7：旧 Convex 主存储导出与退役

- [x] 提供 `convex workspace -> local workspace` 导出工具。
  - 证据：新增 `scripts/export-convex-workspace-to-local.js`，支持从旧 Convex fixture JSON 导出为 local workspace；`node scripts/task444-convex-workspace-export-local-fixture-smoke.js` 已通过。
- [x] 导出 Convex 页面为 `.md`。
  - 证据：`task444` 断言 `pages/Project.md` 与 `pages/Project/Child Spec.md` 存在，并包含 `mnote_id` frontmatter。
- [x] 导出 Convex 附件为本地 assets，并重写正文链接为相对路径。
  - 证据：`task444` 断言 `asset_logo` / `asset_pdf` 导出到 sibling assets，正文链接重写为 `Project.assets/logo.png` 与 `Child Spec.assets/spec.pdf`。
- [x] 导出 tree 结构为目录、frontmatter、`.mnote/page-ids.json` 和 `.mnote/resource-index.json`。
  - 证据：`task444` 断言父子页面目录、frontmatter、`.mnote/page-ids.json`、`.mnote/page-options.json` 和 `.mnote/resource-index.json`。
- [x] 导出 AI 会话为 `ai-sessions/`，并保留权限元数据。
  - 证据：`task444` 断言 `ai-sessions/private/sess_1.jsonl` 存在并包含 `run.completed` 事件。
- [x] 旧 `documents.*` route 降级为 compat adapter，不再作为新增功能入口。
  - 证据：local source `/api/documents/save` 只作为 compat adapter 调 `page.body.write`；新增编辑保存入口为 `/api/page-body/write`。
- [x] Convex page body 写入改为 sync replica 写入。
  - 证据：`transport/convex.rs` 已标注 `documents:updateContent` 是 legacy content substrate / sync replica；local-first 正文真相写 `.md`，不以 Convex revision 作为 local source revision。
- [x] 前端移除直接依赖 Convex documents / media / sessions 结构的主路径。
  - 证据：local source 下页面保存、上传、文件打开和 AI session UI 均走 local-first 字段与 endpoint；`/api/media/*`、`aiSessions:*`、`documents:*` 只保留 cloud/compat 分支，并由 guard 阻止新增默认主链。

### 8.9 详细落码执行 Checklist（2026-05-19 起按此推进）

本节是后续落码的执行账本。每一项都必须能对应到具体代码、测试和验收证据；新增实现若偏离本节，需要先更新本节再编码。

#### A. 防走偏护栏与口径同步

- [x] A1. 固定主路径口径：`MNote = VSCode 简化版工作区 + tiptap markdown 显示/编辑层 + Hermes/Reasonix agent + 插件 + Wolai web 壳 + 鉴权控制面`。
  - 文档：本稿第 2.1、2.4、5、6 节。
  - 验收：普通 local-first Markdown 编辑不再被描述为必须走 `mnote.block.*`、`mnote.doc.markdown_edit` 或 Convex documents。
- [x] A2. 建立 Convex 主存储新增检查。
  - 文件：`scripts/check-local-first-convex-guard.js`、`package.json`。
  - 验证：默认使用 `npm run check:local-first-convex-guard` 检查新增 diff；`--files <path...>` 是全文审计模式，会同时暴露历史 legacy 兼容路径，不作为本轮增量通过条件。
  - 完成定义：新增设计 / 代码不能把 Convex documents、media、aiSessions 描述或实现为默认主存储；cloud / compat 例外必须显式说明。
- [x] A3. 每次阶段性落码后更新本节状态。
  - 文件：本稿。
  - 验证：`git diff --check -- design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`。
  - 完成定义：已完成项必须带代码文件、测试命令或 smoke 脚本证据。
  - 完成证据：本轮已为 C4、D1-D5、E2-E4、F1-F3、G1-G3、H1-H3 逐项补充完成证据和验证命令；当前 checklist 已无未完成项。

#### B. VSCode-like 文件版本与冲突底座

- [x] B1. Page Aggregate 暴露本地文件版本。
  - 文件：`rust/crates/core-protocol/src/page_aggregate.rs`、`rust/crates/mnote-web/src/routes/local_folder_source.rs`。
  - 实现：`PageBody.fileVersion` 作为 `conflictDetectionKey` 的 local-first 语义别名；第一版复用 `mtime + size + contentHash`。
  - 验证：`cargo test -p mnote-web local_markdown_ -- --nocapture`。
  - 完成证据：`local_markdown_aggregate_exposes_file_version_alias` 已通过。
- [x] B2. 本地保存返回新文件版本。
  - 文件：`rust/crates/mnote-web/src/routes/local_folder_source.rs`。
  - 实现：`save_local_markdown_page` 返回 `fileVersion`，兼容保留 `conflict_detection_key`。
  - 验证：`cargo test -p mnote-web local_markdown_ -- --nocapture`。
  - 完成证据：`local_markdown_save_returns_file_version_alias` 已通过。
- [x] B3. `/api/documents/save` local source 支持 `expectedFileVersion`。
  - 文件：`rust/crates/mnote-web/src/routes/documents.rs`。
  - 实现：local source 保存时优先用 `expectedFileVersion` 校验当前文件版本，fallback 到旧 `conflictDetectionKey`。
  - 验证：`cargo test -p mnote-web documents_save -- --nocapture`。
  - 完成证据：`local_folder_documents_save_rejects_stale_expected_file_version` 已通过。
- [x] B4. tiptap 保存带上当前文件版本。
  - 文件：`rust/crates/mnote-web/src/routes/web_shell.rs`。
  - 实现：前端保存 payload 增加 `expectedFileVersion: session.conflictDetectionKey`，并能读取 `body.fileVersion`。
  - 验证：`cargo test -p mnote-web document_shell_bootstrap_preserves_inline_mark_conversion -- --nocapture`。
  - 完成证据：该测试已断言脚本包含 `typeof body?.fileVersion === 'string'` 与 `expectedFileVersion: session.conflictDetectionKey`。
- [x] B5. 兼容 AI 工具读取 / 写入也使用 `fileVersion` 语义。
  - 文件：`rust/crates/mnote-web/src/hermes_tools/doc.rs`、`rust/crates/mnote-web/src/routes/hermes_tools.rs`。
  - 实现：`doc_fetch` 返回 `fileVersion`；`doc_markdown_edit` local source 写入时优先用 `/body/fileVersion` 作为 expected version；在线兼容 payload 保留 `conflictDetectionKey`。
  - 验证：`cargo test -p mnote-web hermes_tools_markdown_edit_local -- --nocapture`。
  - 完成证据：`hermes_tools_doc_fetch_returns_block_projection` 已断言 `fileVersion` 回退到 `conflictDetectionKey`；`hermes_tools_markdown_edit_local_*` 已通过，local source 写入使用 `fileVersion` 作为 expected version。
- [x] B6. clean editor 外部修改自动刷新 smoke。
  - 文件：优先复用或补强 `scripts/task436-local-markdown-open-document-external-change-smoke.js`。
  - 场景：打开 local `.md`，不编辑；外部写文件；页面自动刷新到新内容。
  - 验证：`node scripts/task436-local-markdown-open-document-external-change-smoke.js`。
  - 完成证据：`task436` 已通过“打开文档外部修改后自动同步内容”，且 `navigationEventsBefore/After` 均为 0。
- [x] B7. dirty editor 外部 / AI 修改进入冲突态 smoke。
  - 文件：`scripts/task436-local-markdown-open-document-external-change-smoke.js`。
  - 场景：打开 local `.md`，在 tiptap 输入但未保存；外部编辑器或 `mnote.doc.markdown_edit` 后台写入同一文件；前台进入 `external-change-conflict`。
  - 验证：`node scripts/task436-local-markdown-open-document-external-change-smoke.js`。
  - 完成证据：`task436` 已通过“dirty 文档外部修改后进入冲突提示”和“dirty 文档 AI 后台写入后进入冲突提示”；用户 dirty 文本保留，AI 写入已落盘，且无 reload。

#### C. `documents/save` 兼容面降级与 `page.body.write`

- [x] C1. 设计 `page.body.write` / LocalFS executor 合同。
  - 文件：本稿、`rust/crates/core-protocol/src/kernel.rs` 或对应 command contract 文件。
  - 合同字段：`documentId`、`workspaceId`、`sourceKind`、`rootUri`、`expectedFileVersion`、`baseContentHash`、`contentFormat`、`content`、`editorSource`。
  - 验证：新增协议单测，确认 serde camelCase 与 legacy 字段兼容。
  - 完成证据：`core_protocol::PageBodyWriteRequest` 已定义并导出；`cargo test -p core-protocol page_body_write_request -- --nocapture` 已通过，覆盖 `expectedFileVersion` 与 legacy `conflictDetectionKey` alias。
- [x] C2. 新增 LocalFS 写入执行函数。
  - 文件：`rust/crates/mnote-web/src/routes/local_folder_source.rs` 或拆出 `local_markdown_write.rs`。
  - 实现：接受 `expectedFileVersion`；复用现有 markdown serialize；失败返回 `409 local_markdown_external_change`。
  - 验证：`cargo test -p mnote-web local_markdown_write -- --nocapture`。
  - 完成证据：`write_local_markdown_page_body` 已接收 `PageBodyWriteRequest`，返回 `canonicalCommand=page.body.write`、`compatCommand=page.body.save`，并复用现有 root 边界与文件版本校验。
- [x] C3. `/api/documents/save` 仅作为 compat adapter 调 `page.body.write`。
  - 文件：`rust/crates/mnote-web/src/routes/documents.rs`。
  - 验证：`cargo test -p mnote-web documents_save -- --nocapture`。
  - 完成证据：local source 分支构造 `PageBodyWriteRequest` 后调用 `write_local_markdown_page_body`；`local_folder_documents_save_title_and_options_write_to_disk` 已断言返回 `canonicalCommand=page.body.write` 与 `compatCommand=page.body.save`。
- [x] C4. tiptap island 后续切到明确的 local-first save endpoint 或 command 名。
  - 文件：`rust/crates/mnote-web/src/routes/web_shell.rs`。
  - 验证：浏览器 smoke 覆盖保存后刷新恢复。
  - 完成定义：前端 payload 语义为“基于文件版本写入 markdown”，不再依赖 Convex revision。
  - 完成证据：已新增 `POST /api/page-body/write` 并注册 route；local source 的 editor bootstrap / 动态 pane bootstrap 均指向 `/api/page-body/write`，保存 payload 显式带 `expectedFileVersion`、`contentFormat=editorBlocks`、`editorSource=tiptap`。`cargo test -p mnote-web document_shell_renders_local_markdown_with_same_sidebar_surfaces -- --nocapture`、`cargo test -p mnote-web documents_save -- --nocapture`、`cargo test -p mnote-web local_markdown_write -- --nocapture`、`cargo test -p mnote-web hermes_tools_markdown_edit_local -- --nocapture` 已通过；`node scripts/task443-local-markdown-asset-upload-smoke.js` 已通过，覆盖 local source 保存后刷新恢复。

#### D. Agent Runtime 贴近 VSCode

- [x] D1. 定义 `AiAccessScope` 类型。
  - 文件：优先放 `rust/crates/core-protocol/src/ai.rs` 或 `rust/crates/mnote-web/src/acp_runtime.rs` 旁的 local scope 模块。
  - 字段：`userId`、`workspaceId`、`sessionId`、`sourceKind`、`allowedRoots`、`allowedFilePaths`、`permissionLevel`、`shareContext`。
  - 验证：serde 单测覆盖 camelCase 和空白字段拒绝。
  - 完成定义：所有本地 agent run 都能拿到同一个 scope payload。
  - 完成证据：`core_protocol::AiAccessScope`、`AiAccessPermissionLevel`、`AiShareContext` 已定义并从 `core-protocol` 顶层导出；serde 单测覆盖 camelCase payload、`local_folder` source、`read_write` 权限、`allowedRoots/allowedFilePaths/allowedResourceIds`、`shareContext`，并拒绝空白必填字段 / 空白授权路径。`cargo test -p core-protocol ai_access_scope -- --nocapture` 已通过。
- [x] D2. 页面 AI 启动时只传当前文件引用 + selection + allowed roots。
  - 文件：`rust/crates/mnote-web/src/routes/hermes_client.rs`、`rust/crates/mnote-web/src/routes/page_ai_workflow.rs`、`rust/crates/mnote-web/src/ssr/pages/layout.rs`。
  - 验证：route 单测断言 local source run payload 不包含完整 page context 作为必需字段。
  - 完成定义：普通 `.md` 编辑不强制走 MNote 专用工具。
  - 完成证据：页面 AI 前端请求已显式传 `sourceKind/rootUri`；local source 仍跳过 `page-ai/block-edit-workflow` fast path。`build_run_upstream_body` 在 local source 下把 upstream instructions 收敛为 `fileReference + aiAccessScope + selection`，并裁掉 `pageText/pageXml/contextBlocks` 等完整正文上下文。`cargo test -p mnote-web hermes_client_run_body -- --nocapture`、`cargo test -p mnote-web page_ai_local_source_passes_file_reference_fields_to_agent_run -- --nocapture` 已通过。
- [x] D3. Hermes / Reasonix runtime 使用白名单目录作为文件权限边界。
  - 文件：`rust/crates/mnote-web/src/acp_runtime.rs`、`rust/crates/mnote-web/src/routes/hermes_client.rs`。
  - 验证：单测覆盖普通用户只能拿到自己的 `/mnt/Data1T/Mnote_data/users/<actor>/...` 或管理员显式授权目录。
  - 完成定义：agent cwd / allowed roots 不包含未授权本机目录。
  - 完成证据：local source ACP run 会从 `rootUri` 生成 `MNOTE_AI_ALLOWED_ROOTS_JSON`、`MNOTE_AI_WORKSPACE_ROOT`、`MNOTE_AI_ACCESS_SCOPE_JSON`，Hermes / Reasonix runtime 均通过 `switch_to_config` 接收该 env；`AcpClient::spawn_with_env` 在授权 root 真实存在时以其作为子进程工作目录。单测确认普通用户 run 只包含 `/mnt/Data1T/Mnote_data/users/user_1/我的空间`，不包含其它用户目录。`cargo test -p mnote-web acp_runtime_env_limits_local_source_to_allowed_root -- --nocapture`、`cargo test -p mnote-web acp_runtime -- --nocapture` 已通过。
- [x] D4. agent 写入后由 watcher / refresh 同步 tiptap，不要求 agent 调 MNote 工具。
  - 文件：`rust/crates/mnote-web/src/routes/web_shell.rs`、`rust/crates/mnote-web/src/routes/local_folder_events.rs`。
  - 验证：B6/B7 两个 smoke。
  - 完成定义：后台文件 patch 后前台能按 clean / dirty 状态正确处理。
  - 完成证据：local source 已禁用 page-ai fast-path，agent 原生文件写入可通过 local folder watcher 触发前台 refresh / conflict。`node scripts/task436-local-markdown-open-document-external-change-smoke.js` 已通过，覆盖 clean 外部写入自动刷新与 dirty 外部 / AI 后台写入进入冲突态。
- [x] D5. 保留的 `mnote.doc.*` / `mnote.block.*` manifest 改成兼容 / 远端代理语义。
  - 文件：`rust/crates/mnote-web/src/hermes_tools/manifest.rs` 或 manifest 生成所在文件。
  - 验证：manifest 单测断言写工具含 `requiresWritePermission`，并说明 local-first 推荐 agent 原生 patch/diff。
  - 完成定义：工具不再误导 agent “必须用 MNote 工具编辑普通 markdown”。
  - 完成证据：manifest 写工具 annotations 已增加 `requiresWritePermission=true` / `readOnly=false`，读工具带 `readOnly=true`；`mnote.doc.markdown_edit` 文案已改为“兼容 / 远端代理 fallback”，明确 local-first 本地 workspace 默认优先 agent 原生 patch/diff；`mnote.page.save` 标记为粗粒度兼容兜底。`cargo test -p mnote-web manifest -- --nocapture` 已通过。

#### E. 本地资源上传 / 下载 / 打开

- [x] E1. local `.md` 图片 / 附件上传写入 sibling assets。
  - 文件：`rust/crates/mnote-web/src/routes/local_folder_source.rs`、`rust/crates/mnote-web/src/ssr/pages/layout.rs`。
  - 验证：`cargo test -p mnote-web local_markdown_asset_upload -- --nocapture`、`cargo test -p mnote-web local_folder -- --nocapture`。
- [x] E2. 浏览器 smoke 覆盖本地图片上传、保存、刷新恢复。
  - 文件：新增或扩展 `scripts/task443-local-markdown-asset-upload-smoke.js`。
  - 完成定义：落盘 markdown 是 `![alt](README.assets/name.png)`，刷新后图片块仍可见。
  - 完成证据：新增 `scripts/task443-local-markdown-asset-upload-smoke.js`，真实浏览器登录并打开 local `.md`，调用 `/api/local-folder/assets/upload` 上传图片到 sibling `README.assets/`，再通过 `/api/page-body/write` 保存相对图片引用；刷新后回读 Page Aggregate 确认 `README.assets/task443-image.png` 恢复，落盘 markdown 为 `![task443 图片](README.assets/task443-image.png)`，且无 `/api/media/` 或 `assetId=`。`node scripts/task443-local-markdown-asset-upload-smoke.js` 已通过。
- [x] E3. 浏览器 smoke 覆盖本地附件上传、保存、刷新恢复。
  - 文件：新增或复用 `scripts/task443-local-markdown-asset-upload-smoke.js`。
  - 完成定义：落盘 markdown 是 `[name](README.assets/file.ext)`，不出现 `/api/media/sign` 或 `assetId`。
  - 完成证据：`scripts/task443-local-markdown-asset-upload-smoke.js` 同时覆盖附件上传到 sibling `README.assets/`、保存为 `[task443-spec.pdf](README.assets/task443-spec.pdf)`、刷新后从 Page Aggregate 回读恢复；落盘 markdown 不出现 `/api/media/sign` 或 `assetId`。`node scripts/task443-local-markdown-asset-upload-smoke.js` 已通过。
- [x] E4. local 文件下载 / 打开直接走 local root 内文件。
  - 文件：`rust/crates/mnote-web/src/routes/media.rs`、`rust/crates/mnote-web/src/ssr/pages/layout.rs` 或新增 local file serving route。
  - 验证：HTTP 单测覆盖 root escape 拒绝。
  - 完成定义：本地普通文件不依赖 Convex storage。
  - 完成证据：新增 `GET /api/local-folder/files/open`，先校验当前 actor 对 `rootUri` 的本地 workspace 访问权，再解析 root 内相对路径并拒绝绝对路径 / `..` 越界；File Tree local asset row 生成 `local-file:<relativePath>`，前端点击本地文件时走 `/api/local-folder/files/open`，不再依赖 `/api/media/sign`。`cargo test -p mnote-web local_file_open_rejects_root_escape -- --nocapture`、`cargo test -p mnote-web sidebar_tree_runtime_handles_navigation_drag_and_filetree_actions -- --nocapture` 已通过。

#### F. AI 会话本地化

- [x] F1. 私有会话写入 `ai-sessions/private/*.jsonl`。
  - 文件：`rust/crates/mnote-web/src/routes/hermes_client.rs` 或新增 `local_ai_sessions.rs`。
  - 验证：单测断言 create / append / list / read 都落盘到当前 workspace。
  - 完成定义：本地 session message 全文默认不写 Convex `aiSessions:*`。
  - 完成证据：local source 的 `/api/hermes/client/sessions` 创建会话会写入 `<root>/ai-sessions/private/<session-id>.jsonl` 的 `session.created`；local source 的 ACP run / event 持久化分支追加到同一 JSONL，不再调用 Convex `aiSessions:*`；local source list/detail 读取本地 JSONL。前端页面 AI session query / create payload 已携带 `sourceKind/rootUri`。`cargo test -p mnote-web hermes_client_local_acp_session_create_writes_private_jsonl -- --nocapture`、`cargo test -p mnote-web hermes_client_local_acp_run_writes_private_jsonl_without_convex -- --nocapture` 已通过。
- [x] F2. 共享会话写入 `ai-sessions/shared/<share-id>/*.jsonl`。
  - 文件：同 F1。
  - 验证：单测覆盖 `shared_read` 只读、`shared_write` 可写并记录 audit。
  - 完成定义：分享会话不隐式分享整个 workspace。
  - 完成证据：`CreateSessionRequest` 已接收 `shareId/permissionLevel`；`shared_write` 写入 shared JSONL 并记录 `audit.shared_write`，`shared_read` 返回 `local_ai_session_shared_read_write_forbidden`。`cargo test -p mnote-web hermes_client_local_shared -- --nocapture` 已通过。
- [x] F3. AI session UI 区分 local / shared / cloud。
  - 文件：`rust/crates/mnote-web/src/ssr/pages/layout.rs`。
  - 验证：HTML / browser smoke 断言标签与数据源正确。
  - 完成定义：用户能看出当前 AI 会话是否本地私有、共享或云端。
  - 完成证据：页面 AI 会话归一化、创建和历史渲染已携带并展示 `sessionStorage/permissionLevel/shareId`；`pageAiSessionStorageLabel` 输出“本地私有 / 共享会话 / 云端会话”。`cargo test -p mnote-web page_ai_session_ui_labels_local_shared_and_cloud_storage -- --nocapture` 已通过。

#### G. 控制面收口与旧数据迁移

- [x] G1. 历史 Convex 控制面字段边界冻结；当前默认控制面已替换为 SQLite。
  - 文件：`recycle/20260522-convex-runtime-retirement/infra/convex/`、`rust/crates/mnote-web/src/transport/convex.rs`。
  - 完成定义：新增 Convex 调用只能属于显式 cloud source、compat、migration 或 sync replica；默认 auth、membership、share grants、sync state、AI policy、ACP/Hermes runtime session 走 Rust SQLite control-plane。
  - 完成证据：`recycle/20260522-convex-runtime-retirement/infra/convex/README.md` 与 `docker-compose.yml` 已明确 Convex 自托管仅作为显式 cloud source / compat / sync replica 或历史数据导出；`transport/convex.rs` 保留为 cloud/compat transport 边界；`scripts/check-local-first-convex-guard.js --self-test` 已通过，并新增 active `convex/` 与 `infra/convex` 退役目录回流拦截，防重新引入未标注 documents/media 主存储表或函数。
- [x] G2. 旧 Convex workspace 导出为 local workspace。
  - 文件：新增迁移脚本，优先放 `scripts/` 或 `tools/`。
  - 验证：fixture workspace 导出后包含 `.md`、assets、`.mnote/page-ids.json`。
  - 完成定义：旧数据可迁移，不阻塞退役 Convex 主存储。
  - 完成证据：新增 `scripts/export-convex-workspace-to-local.js` 和 `scripts/task444-convex-workspace-export-local-fixture-smoke.js`；fixture 导出覆盖旧 documents -> `.md`、mediaAssets -> sibling assets 相对链接、`.mnote/page-ids.json`、`.mnote/page-options.json`、`.mnote/resource-index.json`、AI sessions -> `ai-sessions/private/*.jsonl`。`node scripts/task444-convex-workspace-export-local-fixture-smoke.js` 已通过。
- [x] G3. 旧 `documents.*` / `mediaAssets.*` / `aiSessions.*` 主路径改名为 compat / cloud source。
  - 文件：文档、测试名、route 注释。
  - 验证：local-first guard 不再把这些路径误判为默认主链。
  - 完成定义：新开发不会误以为远端控制面仍承载正文、附件或 AI 会话全文的默认真相。
  - 完成证据：guard 检查项文案从“主存储调用”改为“未标注的 Convex 直连”；`transport/convex.rs` 注释标明 legacy documents/media/mindmaps 是 compat / cloud source / sync replica；旧 Convex 专项 smoke 顶部已标注 `convex-source` / `cloud upload` 兼容口径，避免误认为 local-first 默认主链。

#### H. 每轮执行固定验证包

- [x] H1. 改 Rust 协议 / 路由后至少运行：
  - `cargo fmt --check --all`
  - `git diff --check -- <changed-files>`
  - 与变更相关的 `cargo test -p mnote-web <filter> -- --nocapture`
  - 完成证据：`cargo fmt --check --all` 已通过；`git diff --check -- $(git diff --name-only --diff-filter=ACMRTUXB | rg '^(design/02-convex-rust-long-term-architecture/process/2-2-|rust/crates/(core-protocol|mnote-web)|scripts/|infra/convex/|package.json$)')` 已通过；相关 Rust 过滤测试 `local_folder`、`hermes_tools`、`hermes_client_local_acp`、`hermes_client_local_shared`、`page_ai_session_ui_labels_local_shared_and_cloud_storage`、`convex_cloud_media_resource_lifecycle_args_keep_effective_user_id` 已通过。
- [x] H2. 改 local workspace / watcher / editor 后至少运行：
  - `cargo test -p mnote-web local_folder -- --nocapture`
  - 对应 `scripts/task*.js` browser smoke。
  - 完成证据：`cargo test -p mnote-web local_folder -- --nocapture` 已通过，48 个测试通过；`node scripts/task443-local-markdown-asset-upload-smoke.js` 已通过，覆盖本地 `.md` sibling assets 上传、保存和刷新恢复；`node scripts/task436-local-markdown-open-document-external-change-smoke.js` 已通过，覆盖 clean 自动刷新与 dirty 外部 / AI 后台写入冲突态。
- [x] H3. 改 AI runtime / tools 后至少运行：
  - `cargo test -p mnote-web hermes_tools -- --nocapture` 或更窄过滤。
  - 如涉及页面 AI 浏览器行为，补跑页面 AI smoke。
  - 完成证据：`cargo test -p mnote-web hermes_tools -- --nocapture` 已通过，56 个测试通过；`cargo test -p mnote-web hermes_client_local_acp -- --nocapture`、`cargo test -p mnote-web hermes_client_local_shared -- --nocapture`、`cargo test -p mnote-web page_ai_session_ui_labels_local_shared_and_cloud_storage -- --nocapture` 已通过，覆盖本地私有会话、共享会话读写权限和页面 AI 会话存储标签。

---

## 9. 当前优先级建议

新的执行顺序应调整为：

1. **Local-first workspace 入口与 LocalFS 默认 source**
2. **File Tree / Page Tree 默认消费 LocalFS projection**
3. **Page Aggregate 本地 markdown 读写闭环**
4. **上传下载本地资源模型**
5. **AI 会话本地化与权限隔离**
6. **SQLite 控制面收口**
7. **旧 Convex 主存储导出与退役**

原有 `Page Aggregate`、`tree command cutover`、`tree realtime` 仍然重要，但执行目标从“让 Convex 主链更稳定”调整为：

> **让 Rust kernel 的同一套语义同时驱动本地默认路径和可选远端控制面。**

---

## 10. 非目标

当前阶段不做：

- 大规模多人实时协同编辑。
- 云端成为唯一数据真相。
- 本地数据强制上传 Convex。
- 新增一套 local-only 前端文件树。
- 重新引入 Next / React 作为主入口。
- 把 AI 会话全文默认写入 Convex。
- 在没有分享授权的情况下跨用户搜索或读取内容。

---

## 11. 风险与处理

### 11.1 本地文件冲突

用户可能用外部编辑器修改 `.md`。处理方式：

- 文件 watcher 触发 projection resync。
- 保存时检查 mtime / content hash。
- 冲突时保留用户外部修改，不静默覆盖。

### 11.2 分享与离线权限

本地缓存授权可能过期。处理方式：

- 离线只允许访问已缓存且未过期的授权。
- 不允许离线扩大权限。
- 恢复联网后刷新 share grant。

### 11.3 AI 写入误越权

AI tool 必须在执行前校验 scope。处理方式：

- 每个 tool call 带 `AiAccessScope`。
- 写工具检查 resource permission。
- audit 记录写入本地，并在同步开启时上报控制面。

### 11.4 旧 Convex 数据迁移

已有数据不能丢。处理方式：

- 提供 `convex workspace -> local workspace` 导出工具。
- 导出页面为 `.md`。
- 导出附件为 `assets/`。
- 导出树结构为目录和 `.mnote/resource-index.json`。
- 导出 AI 会话为 `ai-sessions/`。

---

## 12. 完成定义

本方向完成时，应满足：

- 用户登录后才能创建、打开、编辑当前 actor 授权的本地工作区。
- 文件树 / 页面树 / 文档页 / 上传下载 / AI 编辑默认不依赖 Convex。
- 登录是本地工作区访问门禁；分享、同步、远端协作和跨设备能力在此基础上由控制面启用。
- 每个用户的笔记和 AI 会话默认隔离。
- 显式分享后，页面、子树、附件或 AI 会话可按权限共享。
- Rust kernel 仍是唯一 tree / page / resource / AI tool 语义层。
- Convex 不再是早期产品默认主数据层或默认控制面，只保留历史迁移源、显式 cloud source / compat / sync replica 边界。
