Storage root placement and derived-medium recovery
存储根目录落点与派生介质恢复
The persisted projection cache ([note](2026-07-27-session-projection-and-command-log.md), shipped as `dsh-session-projection-cache`) surfaced two gaps in the storage substrate it landed on. Both are properties of the domain-KV stack ([design](2026-07-24-domain-kv-storage-and-workspace.md)), not of the cache itself, and both bite the cache first because it is the first *derived* medium on that stack. **Where the files
English
Problem
The persisted projection cache (note, shipped as dsh-session-projection-cache) surfaced two gaps in the storage substrate it landed on. Both are properties of the domain-KV stack (design), not of the cache itself, and both bite the cache first because it is the first derived medium on that stack.
Where the files actually live (root mismatch closed; resolve-once residual still open). The shared base defaults the session store to the global harness home ($DSH_HOME/sessions, default ~/.dsh/sessions), while the shipped Web overlay used to give the json backend the relative root ./.storages: workspace.json and session_projcache.json landed under <launch dir>/.storages/ — two launches from different directories shared their sessions yet saw different workspace registries and different projection caches, and the cache exists precisely to serve the cross-session cold listing, which missed for every session last cached under another launch directory. That mismatch is now closed: the overlay anchors storage-json.root to $DSH_HOME/storages with the same !!js expression the session root uses (apps/cli/config/web.cordis.yml). The residual hazard: JsonStorageBackend still never resolves its root — each unit open joins the path against whatever process.cwd() is at that moment (packages/storage/storage-json/src/index.ts); the shipped overlay root is already absolute and unaffected, but any relative root (bare Loader boots, tests) still splits on a later cwd change — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session/session-persistence-jsonl/src/index.ts).
How recovery works today. Inside a healthy medium the cache is fully self-healing by design: a stateVersion-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the medium level there is no recovery at all: a truncated, hand-edited, or version-bumped session_projcache.json fails openJsonUnit with malformed-medium/version-mismatch (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with invalid-record (packages/storage/storage-domain/src/index.ts), the rejection propagates through SessionProjectionCache[Service.init], and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which today describes an aspiration, not the implementation. The same fail-loud path is correct for workspace.json — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change.
Proposal
Two independent changes, one per gap.
One global storage root (shipped, amended form); resolved once at construction (still open)
- Shipped: the Web overlay anchors
storage-json.rootto$DSH_HOME/storagesdirectly in the row through the app-boot-provideddshHomePath('storages')(~/.dsh/storagesby default, beside~/.dsh/sessions; no leading dot — the home is already a hidden tree). The helper delegates to the canonicaldsh-home-pathsresolver, and the session root uses the same function without duplicating its fallback and tilde rules. The per-row form was chosen (user decision) over a launcher patch +storageRootprofile key (see Alternatives); per-row overrides ride the personal~/.dsh/config.yamlpatch layer. The web e2e scaffold already patches the row to an absolute temp root, so tests never touch the user's home. - Still open:
JsonStorageBackendresolves its configured root once at construction (resolve(config.root)), adopting the JSONL backend's recorded rationale verbatim: a laterprocess.cwd()change must not split one backend across roots. The SQLite storage backend already resolves its path. - Pre-release stance applies (and was executed): no migration shim. A deployment that cached under
<cwd>/.storagesre-derives everything (workspace re-bootstraps from the header index; the projection cache refolds lazily) or moves the two json files by hand once.
Declared derived media: reset instead of reject
DomainSpecgainsrecovery?: 'reject' | 'reset'(default'reject'). The spec object is already the single source of a domain's identity and layout; whether its medium is authoritative or derived is the same kind of fact and lives in the same place.session_projcachedeclares'reset';workspacestays on the default.KvFacetgains one primitive:destroy(descriptor): Promise<void>— remove the unit's medium entirely (json: delete the file; sqlite: drop the unit's tables). Likeopen, it is a backend storage primitive, not policy.DomainFacility.open, when a spec declares'reset'and the open fails with exactly a damage-class error —StorageError('version-mismatch' | 'malformed-medium')orDomainError('invalid-record')— logs one warning naming the domain and the discarded medium, callsdestroy, and opens again empty. Every other failure (backend-not-found,facet-unsupported,already-open, I/O errors) stays loud regardless of the declaration: misconfiguration and environmental faults are not medium damage. The retry is single-shot — a second failure propagates, so a persistently failing medium cannot loop.- With this in place the cache domain spec's version field gains its intended meaning: bumping
version(or letting zod reject drifted rows) genuinely discards the whole medium and the cache rebuilds through its normal write points and cold reads — the recovery ladder's outermost rung, matching the row-level rungs already shipped.
Alternatives considered
Keep per-launch-directory .storages (the pre-change status quo) — rejected: sessions are global, so every derived-from-sessions medium splits against its own source of truth; the cache's motivating scenario (one listing over all sessions) structurally misses rows, and the workspace registry indexes sessions it cannot see from another launch directory.
Launcher patch + a storageRoot profile key — not taken: one !!js yml expression reaches the global root with the same layering the session root already has; a launcher patch adds a second rewrite point, and the profile key is an empty seat until a real consumer exists (per-row overrides already have the personal config.yaml patch layer).
Patch only the projection cache's route to a global root, leave workspace.json per-cwd — rejected: the workspace registry has the identical global-vs-cwd mismatch, and the user chose to place the cache beside workspace.json — one hub root keeps the media co-located and the mental model single.
Cache-plugin-local recovery (catch damage errors in SessionProjectionCache[Service.init], delete the file, reopen) — rejected: the plugin cannot name the medium path without reaching around the backend abstraction, and every future derived domain would re-implement the same catch; the facility is the one place that already classifies open failures.
Fall back to an ephemeral in-memory domain on damage — rejected: it silently degrades to memory-only for the life of the process and the damaged file never heals; the next boot fails the same way.
Rename the damaged medium aside (<unit>.json.corrupt-<ts>) instead of deleting — not chosen: a derived medium's damaged bytes have no recovery value (the logs are the source of truth) and the litter accumulates unbounded; delete is the honest operation. Rename-aside remains the right choice if a future authoritative domain ever wants reset semantics — which is exactly why recovery is per-spec.
A blanket auto-reset for every domain (no spec field) — rejected outright: workspace.json is authoritative user data; silently resetting it on a version bump would destroy workspaces. Authority is a property of the domain and must be declared by its owner.
Acceptance criteria
dshlaunched from any directory reads and writes the same$DSH_HOME/storages/*.json(default~/.dsh/storages) — already satisfied by the overlay expression; per-row overrides ride the personal config.yaml patch layer; the backend resolves a relative root once at construction (still to do).- With a truncated, version-bumped, or schema-drifted
session_projcache.json, the assembly boots clean: one warning names the discarded medium, the file is gone, the cache rebuilds through normal operation, and the cold listing column reappears as sessions are re-checkpointed. - The same damage to
workspace.jsonstill fails boot loudly. - Facility tests cover: each damage class resets a
'reset'domain exactly once; non-damage failures stay loud on a'reset'domain; a'reject'domain propagates every failure;destroyremoves the medium on both shipped backends.
Risks
- Auto-delete on a misclassified error destroys a healthy file. Mitigated by the closed damage-class list: reset fires only on the three deterministic parse-time codes; ENOENT is already "empty unit", and every I/O error (EACCES, EIO) propagates loudly. The single-shot retry bounds the blast radius to one delete per open.
- Root relocation changes where existing checkouts look. Accepted under the pre-release stance (backends reject old formats, no external consumers); the note above records the one-time manual move for anyone who cares about a per-cwd
workspace.json's content. destroyis a new destructive primitive on the storage seam. Its only caller is the facility's declared-reset path; the backend contract documents it as facility-owned, and nothing model-facing or user-facing can reach it.
中文
问题
持久投影缓存(决策记录,已作为 dsh-session-projection-cache 落地)暴露了它所依托的存储基座的两个缺口。二者都是 domain-KV 栈(设计)的属性而非缓存自身的问题,且都首先咬到缓存——因为它是这条栈上第一个派生介质。
文件到底存在哪(根错位已收口,resolve-once 残余仍开放)。 共享 base 将会话存储默认为全局 harness home($DSH_HOME/sessions,默认 ~/.dsh/sessions),而出厂 Web overlay 曾给 json 后端相对根 ./.storages:workspace.json 和 session_projcache.json 落在 <启动目录>/.storages/ 下——从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份,而缓存存在的意义恰恰是跨会话冷列表,凡上次在别的启动目录下缓存过的会话全部 miss。这一错位已消除:overlay 现以与会话根同一段 !!js 表达式把 storage-json.root 锚定到 $DSH_HOME/storages(apps/cli/config/web.cordis.yml)。残余隐患:JsonStorageBackend 仍从不 resolve 根——每次打开 unit 都把路径 join 到当时的 process.cwd() 上(packages/storage/storage-json/src/index.ts);出厂 overlay 的根已是绝对路径不受影响,但任何相对根(裸 Loader 启动、测试)仍会被后续 cwd 变化劈开,JSONL 会话后端用「构造时 resolve 一次」防住的正是它("later process.cwd() changes cannot split one backend across roots",packages/session/session-persistence-jsonl/src/index.ts)。
现在是怎么恢复的。 在健康介质内部,缓存按设计完全自愈: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 却是正确的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。
提案
两个独立改动,一个缺口一个。
全局唯一存储根(已落地,形态修正);构造时 resolve 一次(仍开放)
- 已落地:出厂 Web overlay 通过 app-boot 提供的
dshHomePath('storages'),直接在storage-json行内把root锚定到$DSH_HOME/storages(默认~/.dsh/storages,与~/.dsh/sessions并肩;目录名不带点——home 本身已是隐藏树)。该辅助函数委托给规范的dsh-home-paths解析器,会话根也使用同一个函数,无需重复其回退和波浪号规则。最终选用按行形态(用户决定)而非「launcher patch +storageRootprofile 键」(见 Alternatives);按行覆盖仍走个人~/.dsh/config.yamlpatch 层。web e2e scaffold 本就把该行 patch 到临时绝对根,测试不触用户 home。 - 仍开放:
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 拒绝漂移行)真正丢弃整个介质,缓存经正常写点和冷读重建——恢复阶梯的最外一档,与已落地的行级各档对齐。
备选方案
保持按启动目录的 .storages(改动前现状)——拒绝:会话是全局的,所以每个从会话派生的介质都与自己的真源劈叉;缓存的动机场景(一次列出全部会话)结构性丢行,工作区注册表索引着从另一个启动目录看不见的会话。
launcher patch + storageRoot profile 键——未采:一行 yml !!js 表达式即达全局根,与会话根的既有分层完全一致;launcher patch 多引入一个改写点,profile 键在有真实消费方前是空席(按行覆盖已有个人 config.yaml patch 层可用)。
只把投影缓存的 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 时静默重置会毁掉工作区。权威性是域的属性,必须由其所有者声明。
验收标准
- 从任意目录启动
dsh都读写同一份$DSH_HOME/storages/*.json(默认~/.dsh/storages)——已由 overlay 表达式满足,按行覆盖走个人 config.yaml patch 层;后端对相对根在构造时 resolve 一次(待做)。 session_projcache.json被截断、版本 bump 或 schema 漂移时,组装干净启动:一条警告命名被丢弃的介质,文件消失,缓存经正常运转重建,冷列表列随会话重新 checkpoint 逐步回归。- 同样的损坏发生在
workspace.json上仍大声拒绝启动。 - facility 测试覆盖:每个损坏类恰好重置一次
'reset'域;非损坏失败在'reset'域上保持大声;'reject'域传播一切失败;destroy在两个出厂后端上都移除介质。
风险
- 错误分类失误导致自动删除健康文件。 由封闭的损坏类清单缓解:重置只在三个确定性解析期代码上触发;ENOENT 本来就是「空 unit」,一切 I/O 错误(EACCES、EIO)大声传播。单发重试把爆炸半径限定为每次 open 至多一删。
- 根迁移改变既有 checkout 的查找位置。 在 pre-release 立场下接受(后端拒绝旧格式、无外部消费方);上文为在乎 per-cwd
workspace.json内容的人记录了一次性手动搬移。 destroy是存储 seam 上新增的破坏性原语。 唯一调用方是 facility 的声明重置路径;后端约定将其记档为 facility 专属,任何面向模型或面向用户的路径都触不到它。