DSH / Atlas
2026-07-28implementedarchitecture

user-settings seam (`ctx.settings`) and the file provider

用户设置 seam(`ctx.settings`)与文件提供方

User-editable configuration had no owner: `dsh web` read a cwd-anchored profile json through a static whitelist with no write path, the TUI read `$DSH_HOME/config.yaml` raw loader patches, and both froze at boot. A personal-settings page (web GUI) needs one cross-surface user layer with schema validation, a write path, and hot propagation — and peer products (Codex, Claude Code, Kimi, OpenCode, Pi) all converged on s

English

Problem

User-editable configuration had no owner: dsh web read a cwd-anchored profile json through a static whitelist with no write path, the TUI read $DSH_HOME/config.yaml raw loader patches, and both froze at boot. A personal-settings page (web GUI) needs one cross-surface user layer with schema validation, a write path, and hot propagation — and peer products (Codex, Claude Code, Kimi, OpenCode, Pi) all converged on separating user preferences from extension composition. Loader-reactive config updates cannot carry this: fiber.update swaps entry config in place, so a plugin that read config at construction observes nothing and no callback tells it otherwise.

Decision

Two planes with a litmus test. cordis.yml (+ Include patches) stays the composition plane: which plugins exist, wiring, deployment config, owned by the orchestrator and upgraded with the product. A settings namespace carries only the user-editable subset; the test is "should the personal config page edit it?" Values live in both planes without ambiguity because layering is the contract: schema defaults, then the registrant's composition base (its entry-config subset), then the user document section.

Three-package boundary mirroring session-persistence/. dsh-settings owns the abstract SettingsProvider service: namespace registry, layered resolution, schema validation, per-namespace deep-equal change detection, and the settings/updated commit event. Providers implement only writable/load()/persist(ns, section) and push externally observed documents through the protected publish(doc) — so hot-update semantics are identical across providers, and a network configuration-center backend (nacos-style, possibly read-only) is a sibling package away. dsh-settings-file is the file provider: YAML/JSON under resolveSpec (explicit defaulting to <DSH_HOME>/settings.yaml), chokidar watch, read-modify-write persists under a cross-process writer lock with atomic 0600 tmp+rename commits, leaf-level diff patching of the written namespace (comments survive untouched nodes), and content-equality self-write suppression (write-path integrity note).

Registrations are caller-fiber effects. register() runs through the service proxy, so this.ctx is the registrant's context and the registration rides ctx.effect: disposing the registrant removes the namespace and its watchers (proven by the HMR disposal test), while the user's section keeps living in storage for the next owner.

Fail loud at rest, last-good in motion. Boot-time and registration-time validation throw (invalid stored section fails the registering plugin; an existing-but-unparsable document fails provider load). Once live, a bad external edit warns and keeps the last good state per namespace — a hot reload must never take the process down. This asymmetry mirrors Include.refresh() and Kimi's safe runtime reload.

Consumers stay optional-by-construction. A consumer registers inside ctx.inject(['settings'], …); without a mounted provider it keeps resolving entry config alone, so every existing composition, demo, and snapshot works unchanged and migration is per-plugin.

Alternatives considered

  • Include write-back as the user layer (per-plugin config pages writing loader entry files, cordis-webui style): write-back would target per-composition files, binding user preferences to one cordis.yml; a per-user layer must survive template upgrades and serve TUI and web from one document.
  • Loader-reactive fiber.update as the propagation channel: constructor-time reads observe nothing; the seam's explicit watch() makes hot-update a consumer contract instead of framework magic.
  • A domain-aware settings service (getters per product area): rejected as coupling; the service stores, validates, and publishes — domain meaning stays with the registrant that owns the schema.
  • Multi-layer precedence now (system/managed/project tiers à la Codex/Claude Code): deferred until a real second layer exists; the resolve step is the single place layering would extend.
  • A cross-process lockfile now (Pi's proper-lockfile): initially deferred as "atomic replace plus watcher convergence until real contention shows up" — but convergence loses unobserved sibling namespaces, so the deferral is superseded by the write-path integrity note's hand-rolled writer lock.

Consequences

Deferred, in dependency order: the web settings.raw/settings.describe/settings.update RPC surface (which must redact role('secret') fields before exposure); first consumer migrations (ui-theme, locale, api-gateway default route) retiring PROFILE_MAPPINGS and the profile json; ${env:VAR} value indirection for secrets; provider-side layering. The keyless snapshot obligation lands with the first model- or product-user-visible consumer, not with this infrastructure step.

中文

问题

用户可编辑配置没有归属: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/ 的三包边界。 dsh-settings 拥有抽象 SettingsProvider 服务:namespace 注册表、分层解析、schema 校验、按 namespace 深相等变更检测,以及 settings/updated 提交事件。提供方只实现 writable/load()/persist(ns, section),并通过受保护的 publish(doc) 推入外部观察到的文档——因此热更新语义对所有提供方一致,网络配置中心后端(nacos 类,可能只读)只是一个平级包的距离。dsh-settings-file 是文件提供方:由 resolveSpec 定位的 YAML/JSON(默认路径显式设为 <DSH_HOME>/settings.yaml)、chokidar 监听、在跨进程写锁下以 0600 tmp+rename 原子提交的读改写持久化、对被写 namespace 的叶子级 diff 修补(未触碰节点的注释得以保留)、按内容相等抑制自写(write-path integrity note)。

注册是调用方 fiber 上的 effect。 register() 经服务代理调用,this.ctx 即注册方上下文,注册挂在 ctx.effect 上:对注册方执行 dispose(资源释放)时,即移除 namespace 及其观察者(经 HMR(热模块替换)资源释放测试证明),而用户的分节继续留在存储中等待下一任 owner。

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

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

备选方案

  • 以 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 的手写写锁取代。

后果

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