# Agent Note：用户设置 seam（`ctx.settings`）与文件 provider

Status: implemented

[English](2026-07-28-user-settings-seam.md) | 中文

> 范围：`packages/settings/` 能力族——抽象 seam、文件 provider，以及用户设置与 `cordis.yml` 的组合边界。[web config-tree note](2026-07-24-web-config-tree-boot-and-transport-layering.md) 曾把"profile 写路径"记为延后项；本 seam 就是该写路径的归属。消费者迁移（主题、语言、默认模型路由）与 web `settings.*` RPC 面是后续工作，不在本 note 已交付范围内。

## 问题

用户可编辑配置没有归属：`dsh web` 经静态白名单读 cwd 锚定的 profile json 且无写路径，TUI 读 `$DSH_HOME/config.yaml` 裸 loader patch，两者都在启动时冻结。个人设置页（web GUI）需要一个跨 surface 的用户层，带 schema 校验、写路径与热传导——同类产品（Codex、Claude Code、Kimi、OpenCode、Pi）也全部收敛于"用户偏好与扩展组合分离"。Loader 的 reactive 配置更新承载不了这件事：`fiber.update` 原地替换 entry config，构造期读过配置的插件毫无感知，也没有任何回调通知它。

## 决策

**两个面，一条判定。**`cordis.yml`（+ Include patches）仍是组合面：有哪些插件、接线、部署配置，归 orchestrator 所有并随产品升级。settings namespace 只承载用户可编辑子集；判定是"个人配置页应该能改它吗？"值可同时存在于两个面而不歧义，因为分层就是契约：schema 默认值，然后注册方的组合 `base`（其 entry 配置子集），最后用户文档分节。

**镜像 `session-persistence/` 的三包 seam。**`dsh-settings` 拥有抽象 `Settings` 服务：namespace 注册表、分层解析、schema 校验、按 namespace 深相等变更检测，以及 `settings/updated` 提交事件。provider 只实现 `writable`/`load()`/`persist(ns, section)`，并通过受保护的 `publish(doc)` 推入外部观察到的文档——因此热更新语义对所有 provider 一致，网络配置中心后端（nacos 类，可能只读）只是一个平级包的距离。`dsh-settings-local` 是文件 provider：`resolveSpec` 显式默认到 `<DSH_HOME>/settings.yaml` 的 YAML/JSON、chokidar 监听、跨进程写锁下以 `0600` tmp+rename 原子提交的读-改-写 persist、对被写 namespace 的叶子级 diff 修补（未触碰节点的注释得以保留）、按内容相等抑制自写（[write-path integrity note](2026-07-30-settings-write-path-integrity.md)）。

**注册是调用方 fiber 上的 effect。**`register()` 经服务代理调用，`this.ctx` 即注册方 context，注册挂在 `ctx.effect` 上：dispose 注册方即移除 namespace 及其观察者（HMR disposal 测试证明），而用户的分节继续留在存储中等待下一任 owner。

**静止时响亮报错，运行中保留最后可用值。**启动期与注册期校验直接抛错（非法存量分节使注册插件加载失败；存在但不可解析的文档使 provider 加载失败）。运行中坏的外部编辑只告警并按 namespace 保留最后可用状态——热重载绝不拖垮进程。该不对称镜像 `Include.refresh()` 与 Kimi 的安全运行时重载。

**消费者天然可选。**消费者在 `ctx.inject(['settings'], …)` 内注册；不挂 provider 时仍只按 entry 配置解析，因此所有既有组合、demo、snapshot 原样工作，迁移按插件渐进。

## Alternatives considered

- **以 Include 写回为用户层**（cordis-webui 式的按插件配置页写 loader entry 文件）：写回目标是按组合的文件，会把用户偏好绑死在某个 `cordis.yml` 上；用户层必须在模板升级中存活，并以同一文档服务 TUI 与 web。
- **以 Loader reactive `fiber.update` 为传导通道**：构造期读取毫无感知；seam 的显式 `watch()` 把热更新变成消费者契约而非框架魔法。
- **领域化的 settings 服务**（按产品域的 getter）：设计评审中的耦合反对成立；服务只做存储、校验、发布——领域含义留给拥有 schema 的注册方。
- **现在就做多层优先级**（Codex/Claude Code 式 system/managed/project 层级）：延后到真实第二层出现；resolve 步骤是分层未来唯一的扩展点。
- **现在就上跨进程锁**（Pi 的 proper-lockfile）：最初以"原子替换加 watcher 收敛，真实冲突出现再说"为由延后——评审发现收敛会丢失未观察到的同级 namespace，因此该延后已被 [write-path integrity note](2026-07-30-settings-write-path-integrity.md) 的手写写锁取代。

## 后果

按依赖顺序延后：web `settings.raw`/`settings.describe`/`settings.update` RPC 面（暴露前必须对 `role('secret')` 字段脱敏）；首批消费者迁移（`ui-theme`、语言、api-gateway 默认路由）并退役 `PROFILE_MAPPINGS` 与 profile json；面向密钥的 `${env:VAR}` 值间接引用；provider 侧分层。keyless snapshot 义务随第一个模型或产品用户可见的消费者落地，而非本基础设施步骤。
