# Agent Note：存储根目录落点与派生介质恢复

Status: proposed

[English](2026-07-28-storage-root-and-derived-medium-recovery.md) | 中文

## Problem

持久投影缓存（[RFC](2026-07-27-session-projection-and-command-log.md)，已作为 `dsh-session-projection-cache` 落地）暴露了它所依托的存储基座的两个缺口。二者都是 domain-KV 栈（[设计](2026-07-24-domain-kv-storage-and-workspace.md)）的属性而非缓存自身的问题，且都首先咬到缓存——因为它是这条栈上第一个*派生*介质。

**文件到底存在哪。** 出厂 Web overlay 给 json 后端的是相对根目录——`root: './.storages'`（`apps/cli/config/web.cordis.yml`）——而共享 base 将会话存储默认为全局 harness home（`$DSH_HOME/sessions`，默认 `~/.dsh/sessions`）；`storage-json` 没有对应的全局根目录。`JsonStorageBackend` 自己也从不 resolve 根——每次打开 unit 都把仍然相对的路径 join 到当时的 `process.cwd()` 上（packages/storage/storage-json/src/index.ts）——这正是 JSONL 会话后端用「构造时 resolve 一次」防住的那个隐患（"later process.cwd() changes cannot split one backend across roots"，packages/session-persistence/session-persistence-jsonl/src/index.ts）。净效果：会话日志跨启动目录全局共享，但 `workspace.json` 和 `session_projcache.json` 落在 `<启动目录>/.storages/` 下。从两个不同目录启动，会话相同，工作区注册表和投影缓存却各是一份——而缓存存在的意义恰恰是跨会话冷列表，如今凡是上次在别的启动目录下缓存过的会话全部 miss。

**现在是怎么恢复的。** 在健康介质内部，缓存按设计完全自愈：`stateVersion` 不匹配的行被丢弃重折，日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答，每次后台写都是 fail-soft。但在*介质*层面完全没有恢复：被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit` 以 `malformed-medium`/`version-mismatch` 失败（packages/storage/storage-json/src/format.ts），schema 漂移的记录让域 open 以 `invalid-record` 失败（packages/storage/storage-domain/src/index.ts），拒绝一路穿过 `SessionProjectionCache[Service.init]`，在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场（"a stale or unreadable cache costs a longer tail replay, never a wrong value"）和缓存域 spec 的 JSDoc（"version bumps discard the whole medium"）相矛盾——后者今天描述的是愿望而非实现。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据，不可派生——所以缺的概念是按域声明权威性，而不是全局改行为。

## Proposal

两个独立改动，一个缺口一个。

### 全局唯一存储根，构造时 resolve 一次

- `AppCLIEntry.composePatches` 的 Source 0 追加把 `storage-json.root` patch 到 `join(resolveDshHome(), 'storages')`——默认 `~/.dsh/storages`，与 `~/.dsh/sessions` 并肩——并且 `PROFILE_MAPPINGS` 增加 `storageRoot` →（`storage-json`，`root`），与 `persistenceRoot` 完全镜像。yml 保留 `./.storages` 作为裸组合的工程默认（测试和裸 Loader 启动不受影响），分层方式与今天的会话根相同。
- `JsonStorageBackend` 在构造时对配置根 `resolve` 一次，原样采纳 JSONL 后端已记录的理由：后续 `process.cwd()` 变化不得把一个后端劈到多个根下。SQLite 存储后端已经 resolve 其路径。
- 适用 pre-release 立场：不做迁移垫片。曾在 `<cwd>/.storages` 下缓存过的部署要么全部重新派生（工作区从 header 索引重新 bootstrap；投影缓存惰性重折），要么手动把两个 json 文件挪一次。

### 声明派生介质：损坏时重置而非拒绝

- `DomainSpec` 增加 `recovery?: 'reject' | 'reset'`（默认 `'reject'`）。spec 对象已经是一个域的身份与布局的单一来源；其介质是权威还是派生属于同类事实，落在同一处。`session_projcache` 声明 `'reset'`；`workspace` 保持默认。
- `KvFacet` 增加一个原语：`destroy(descriptor): Promise<void>`——整体移除该 unit 的介质（json：删文件；sqlite：drop 该 unit 的表）。与 `open` 一样，它是后端存储原语，不是策略。
- `DomainFacility.open` 在 spec 声明 `'reset'` 且 open 恰以损坏类错误失败时——`StorageError('version-mismatch' | 'malformed-medium')` 或 `DomainError('invalid-record')`——记一条命名该域和被丢弃介质的警告，调用 `destroy`，再空开一次。其余一切失败（`backend-not-found`、`facet-unsupported`、`already-open`、I/O 错误）无论声明与否都保持大声：配置错误和环境故障不是介质损坏。重试单发——第二次失败原样传播，持续失败的介质不会成环。
- 有了这个，缓存域 spec 的 version 字段才获得其本意：bump `version`（或让 zod 拒绝漂移行）真正丢弃整个介质，缓存经正常写点和冷读重建——恢复阶梯的最外一档，与已落地的行级各档对齐。

## Alternatives considered

**保持按启动目录的 `.storages`（现状）**——拒绝：会话是全局的，所以每个从会话派生的介质都与自己的真源劈叉；缓存的动机场景（一次列出全部会话）结构性丢行，工作区注册表索引着从另一个启动目录看不见的会话。

**只把投影缓存的 route 指到全局根，`workspace.json` 留在 per-cwd**——拒绝：工作区注册表有一模一样的全局 vs per-cwd 错位，而且塑造缓存的用户决策就是刻意把它放在 `workspace.json` 旁边——一个 hub 根让介质同址、心智模型单一。

**缓存插件本地恢复（在 `SessionProjectionCache[Service.init]` 捕获损坏错误、删文件、重开）**——拒绝：插件不越过后端抽象就叫不出介质路径，且未来每个派生域都要重抄同一段 catch；facility 是唯一已经在分类 open 失败的地方。

**损坏时退到内存态临时域**——拒绝：进程余生静默降级为仅内存，损坏文件永不自愈；下次启动照样失败。

**把损坏介质改名旁置（`<unit>.json.corrupt-<ts>`）而非删除**——未选：派生介质的损坏字节没有恢复价值（日志才是真源），残骸无界累积；删除才是诚实的操作。若未来某个*权威*域想要重置语义，旁置改名才是对的——这正是 `recovery` 按 spec 声明的理由。

**所有域一律自动重置（不加 spec 字段）**——断然拒绝：`workspace.json` 是权威用户数据；版本 bump 时静默重置会毁掉工作区。权威性是域的属性，必须由其所有者声明。

## Acceptance criteria

- 从任意目录启动 `dsh` 都读写同一份 `$DSH_HOME/storages/*.json`（默认 `~/.dsh/storages`）；profile 键 `storageRoot` 可覆盖；裸 Loader 启动 yml 仍落在相对启动 cwd 的 `./.storages`,并在后端构造时 resolve 一次。
- `session_projcache.json` 被截断、版本 bump 或 schema 漂移时，组装干净启动：一条警告命名被丢弃的介质，文件消失，缓存经正常运转重建，冷列表列随会话重新 checkpoint 逐步回归。
- 同样的损坏发生在 `workspace.json` 上仍大声拒绝启动。
- facility 测试覆盖：每个损坏类恰好重置一次 `'reset'` 域；非损坏失败在 `'reset'` 域上保持大声；`'reject'` 域传播一切失败；`destroy` 在两个出厂后端上都移除介质。

## Risks

- **错误分类失误导致自动删除健康文件。** 由封闭的损坏类清单缓解：重置只在三个确定性解析期代码上触发；ENOENT 本来就是「空 unit」,一切 I/O 错误（EACCES、EIO）大声传播。单发重试把爆炸半径限定为每次 open 至多一删。
- **根迁移改变既有 checkout 的查找位置。** 在 pre-release 立场下接受（后端拒绝旧格式、无外部消费者）；上文为在乎 per-cwd `workspace.json` 内容的人记录了一次性手动搬移。
- **`destroy` 是存储 seam 上新增的破坏性原语。** 唯一调用方是 facility 的声明重置路径；后端契约将其记档为 facility 专属,任何面向模型或面向用户的路径都触不到它。
