diff --git a/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.i18n.yaml
new file mode 100644
index 0000000000..ef7c78129d
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-18-volatile-config-references.md
+2026-09-18-volatile-config-references.md: afaa6affdc3d18da182ec069126033cb55393b72
+2026-09-18-volatile-config-references.zh.md: 3d8800cb92dec4da80b698cb8b3c073331ed266f
diff --git a/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.md b/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.md
new file mode 100644
index 0000000000..afaa6affdc
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.md
@@ -0,0 +1,33 @@
+# Agent Note: Volatile configuration references
+
+Status: implemented
+
+English | [中文](2026-09-18-volatile-config-references.zh.md)
+
+## Problem
+
+Replacing an entire plugin to change a value also disposes its services and effects. Separate settings subscriptions require each consumer to implement another source of live configuration.
+
+## Decision
+
+Schemastery declares live fields with `.volatile()`, preserving node types and form metadata while returning stable references to immutable snapshots. Cosmokit owns the shared protocol. General comparison treats two volatile references as equal. Loader calculates raw config differences using schema-declared volatile paths and retains raw config for subsequent activation. When the raw diff reports no ordinary change but the raw config differs, Loader parses and validates the candidate through the fiber's `internal/config` hook, compares ordinary effective values, commits references when those values still match, and notifies the owning fiber; every update path, including profile reconciliation, HMR Include refreshes and direct `entry.update()` calls, reaches this step through `Entry.update`. Fiber does not own this update policy, and HMR supplies file watching.
+
+Only volatile changes preserve the instance. Ordinary-field changes use the existing remount lifecycle and do not update the old references. An instance-local `loader/volatile-update` event reports paths after the complete candidate is validated and all references are committed. It uses ordinary `emit`; listener completion is not a configuration commit condition. References can be retained, while values may only be captured for one operation. Missing optional values retain a readable reference.
+
+Config schemas own the field types, defaults, roles, and live-update declaration needed by schema-driven settings forms. Existing settings consumers remain unchanged; automatic form generation and their migration are separate work. The [configuration guide](../../../../docs/cordis-tutorial/05-config.md) owns plugin-author usage.
+
+## Alternatives considered
+
+**Restart for every configuration change.** This releases resources that remain valid when only operation-time parameters change.
+
+**Add another settings-specific live-value API.** It duplicates runtime update semantics and makes plugins depend on settings to consume ordinary configuration.
+
+**Wrap values only during mounting.** Direct schema parsing would return a different kind of value from the plugin's config, requiring separate consumer types.
+
+## Consequences
+
+Schemas reject independent volatile references inside dynamic containers; entire objects and arrays can be volatile values. Immutable snapshots accept plain data. Simplification unwraps references, and shared symbols support ESM/CJS copies. Group/Include retain dispatch while volatile-only target updates bypass `internal/update`. Direct `fiber.update()` retains its semantics, including custom handling, saving and `noSave`. Loader commits live volatile updates itself; no separate plugin is required.
+
+Schema metadata keeps comparison independent of parsing: the diff does not execute expressions, validators or config hooks. Object nodes compare missing or null values using their declared defaults, even without volatile descendants; this avoids remounts for equivalent object defaults. Ordinary expression edits retain raw comparison even when they resolve to equal values. Loader compares parsed ordinary values, including URLs by normalized address. If an ordinary expression produces a different value, Loader applies the ordinary remount instead of committing; strict comparison treats class instances other than URL, Date and RegExp by identity, so an ordinary transform that builds a fresh instance per parse always remounts. Listener failures during the notification are logged and do not fail the update. Loader validates volatile candidates before committing; invalid edits remain in raw config but leave running references unchanged until a later activation.
+
+The schema and lifecycle regression suite covers direct updates, invalid candidates, instance isolation, dependency replacement, real file watching, and save/reload behavior. The HMR module suite covers replacement after live config updates. These tests do not claim settings migration or automatic form rendering.
diff --git a/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.zh.md b/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.zh.md
new file mode 100644
index 0000000000..3d8800cb92
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.zh.md
@@ -0,0 +1,33 @@
+# Agent Note: Volatile 配置引用
+
+Status: implemented
+
+[English](2026-09-18-volatile-config-references.md) | 中文
+
+## Problem
+
+为了改变一个值而替换整个插件,也会销毁插件的服务和副作用。独立的 settings 订阅要求每个消费者额外实现一套动态配置来源。
+
+## Decision
+
+Schemastery 使用 `.volatile()` 声明动态字段,保留节点类型和表单元数据,解析结果是具有不可变快照的稳定引用。共享协议位于 Cosmokit。通用比较将两个 volatile 引用视为相等。Loader 根据 schema 声明的 volatile 路径计算原始配置差异,并保留原始配置供后续激活使用。原始 diff 未报告普通字段变化但原始配置有差异时,Loader 经 fiber 的 `internal/config` 钩子解析并校验候选配置,比较普通字段的有效值,在这些值仍匹配时提交引用并通知所属实例;所有更新路径,包括 profile 重载、HMR 的 Include 刷新和直接调用 `entry.update()`,都经 `Entry.update` 到达这一步。Fiber 不承担这项更新策略,文件监视由 HMR 提供。
+
+仅 volatile 变化保留实例。普通字段变化沿用现有重新挂载生命周期,不更新旧引用。实例局部的 `loader/volatile-update` 事件在完整候选配置通过校验且所有引用提交后报告路径。事件使用普通 `emit`;监听器完成不是配置提交条件。可以保存引用,而值只能在单次操作中保存。缺省可选值仍具有可读取的引用。
+
+Config schema 负责提供基于 schema 的 settings 表单需要的字段类型、默认值、角色和动态更新声明。现有 settings 消费者保持不变;自动表单生成及其迁移是独立工作。[配置指南](../../../../docs/cordis-tutorial/05-config.zh.md)负责插件作者的使用说明。
+
+## Alternatives considered
+
+**每次配置变化都重启。** 当只有操作时读取的参数变化时,这会释放仍然有效的资源。
+
+**增加另一套 settings 专用动态值 API。** 这会重复运行时更新语义,并使插件为了读取普通配置而依赖 settings。
+
+**仅在挂载时包装值。** 直接解析 schema 与插件配置将返回不同种类的值,需要独立的消费者类型。
+
+## Consequences
+
+Schema 拒绝动态容器内部的独立 volatile 引用;整个对象和数组可以作为一个 volatile 值。不可变快照接受普通数据。配置简化解包引用,共享 symbol 支持 ESM/CJS 副本互操作。Group/Include 保留分发,目标插件的仅 volatile 更新不进入 `internal/update`。直接调用 `fiber.update()` 保留原语义,包括自定义处理、保存及 `noSave`。动态 volatile 更新由 Loader 自己提交,不需要额外插件。
+
+Schema 元数据使比较独立于解析:diff 不执行表达式、校验器或配置钩子。对象节点的缺省值或 null 按声明的默认值比较,即使没有 volatile 后代字段;等价的对象默认值不触发重新挂载。普通表达式即使求值结果相等,修改时仍按原始值比较。Loader 比较解析后的普通值,其中 URL 按规范化地址比较。普通表达式产生不同值时,Loader 改为按普通流程重新挂载而不提交;严格比较对 URL、Date、RegExp 之外的类实例按身份比较,因此每次解析都产生新实例的普通 transform 字段总是走重新挂载。通知阶段监听器抛错只记录日志,不使更新失败。Loader 在提交前校验 volatile 候选;非法编辑保留在原始配置中,但在后续激活之前不改变运行中的引用。
+
+Schema 和生命周期回归测试覆盖直接更新、无效候选、实例隔离、依赖替换、真实文件监视及保存后重新加载。HMR 模块测试覆盖动态配置更新后的代码替换。这些测试不代表 settings 迁移或自动表单渲染已经完成。
diff --git a/docs/cordis-api/inherited.md b/docs/cordis-api/inherited.md
index bb6cd40bb3..cc62683cb5 100644
--- a/docs/cordis-api/inherited.md
+++ b/docs/cordis-api/inherited.md
@@ -21,16 +21,18 @@ This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verifie
## Inherited events (cordis core + loader/hmr/timer)
-- `internal/plugin` — A plugin fiber was created. ([`vendor/cordis/src/events.ts:328`](../../vendor/cordis/src/events.ts))
-- `internal/status` — A fiber changed lifecycle state. ([`vendor/cordis/src/events.ts:330`](../../vendor/cordis/src/events.ts))
-- `internal/service` — Interception hook for a service binding (no core producer). ([`vendor/cordis/src/events.ts:332`](../../vendor/cordis/src/events.ts))
-- `internal/update` — Waterfall: a fiber config update is being applied. ([`vendor/cordis/src/events.ts:334`](../../vendor/cordis/src/events.ts))
-- `internal/get` — Waterfall: a service is being read from the store. ([`vendor/cordis/src/events.ts:336`](../../vendor/cordis/src/events.ts))
-- `internal/set` — Waterfall: a service is being written to the store. ([`vendor/cordis/src/events.ts:338`](../../vendor/cordis/src/events.ts))
-- `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:340`](../../vendor/cordis/src/events.ts))
-- `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:342`](../../vendor/cordis/src/events.ts))
-- `exit` — The process is exiting on a signal. ([`vendor/loader/src/index.ts:23`](../../vendor/loader/src/index.ts))
-- `loader/config-update` — The loader config tree changed. ([`vendor/loader/src/index.ts:24`](../../vendor/loader/src/index.ts))
-- `loader/entry-init` — A config entry is being initialized. ([`vendor/loader/src/index.ts:25`](../../vendor/loader/src/index.ts))
-- `loader/partial-dispose` — An entry is being partially disposed on reload. ([`vendor/loader/src/index.ts:26`](../../vendor/loader/src/index.ts))
-- `loader/patch-context` — A context is being patched during a reload. ([`vendor/loader/src/index.ts:27`](../../vendor/loader/src/index.ts))
+- `internal/plugin` — A plugin fiber was created. ([`vendor/cordis/src/events.ts:331`](../../vendor/cordis/src/events.ts))
+- `internal/status` — A fiber changed lifecycle state. ([`vendor/cordis/src/events.ts:333`](../../vendor/cordis/src/events.ts))
+- `internal/service` — Interception hook for a service binding (no core producer). ([`vendor/cordis/src/events.ts:341`](../../vendor/cordis/src/events.ts))
+- `internal/update` — Waterfall: a fiber config update is being applied. ([`vendor/cordis/src/events.ts:343`](../../vendor/cordis/src/events.ts))
+- `internal/config` — Waterfall: a fiber config is being resolved before validation. ([`vendor/cordis/src/events.ts:339`](../../vendor/cordis/src/events.ts))
+- `internal/get` — Waterfall: a service is being read from the store. ([`vendor/cordis/src/events.ts:345`](../../vendor/cordis/src/events.ts))
+- `internal/set` — Waterfall: a service is being written to the store. ([`vendor/cordis/src/events.ts:347`](../../vendor/cordis/src/events.ts))
+- `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:349`](../../vendor/cordis/src/events.ts))
+- `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:351`](../../vendor/cordis/src/events.ts))
+- `exit` — The process is exiting on a signal. ([`vendor/loader/src/index.ts:25`](../../vendor/loader/src/index.ts))
+- `loader/config-update` — The loader config tree changed. ([`vendor/loader/src/index.ts:26`](../../vendor/loader/src/index.ts))
+- `loader/entry-init` — A config entry is being initialized. ([`vendor/loader/src/index.ts:27`](../../vendor/loader/src/index.ts))
+- `loader/partial-dispose` — An entry is being partially disposed on reload. ([`vendor/loader/src/index.ts:28`](../../vendor/loader/src/index.ts))
+- `loader/patch-context` — A context is being patched during a reload. ([`vendor/loader/src/index.ts:41`](../../vendor/loader/src/index.ts))
+- `loader/volatile-update` — Volatile config values committed into the running fiber without a remount; dispatched to the owning fiber only. ([`vendor/loader/src/index.ts:34`](../../vendor/loader/src/index.ts))
diff --git a/docs/cordis-tutorial/05-config.i18n.yaml b/docs/cordis-tutorial/05-config.i18n.yaml
index 2e7c3b22ec..3eb71ac95b 100644
--- a/docs/cordis-tutorial/05-config.i18n.yaml
+++ b/docs/cordis-tutorial/05-config.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/cordis-tutorial/05-config.md
-05-config.md: 17cccce2ec43be65477ce527800ee6a636ee5d96
-05-config.zh.md: e19dcb3ab0082cdc6ba2457158491ba47223409d
+05-config.md: f278e8d06ef867d2944c279d067c36970582167f
+05-config.zh.md: 65e9ea73a5ce31776dc1603339fa7c1c0250f856
diff --git a/docs/cordis-tutorial/05-config.md b/docs/cordis-tutorial/05-config.md
index 17cccce2ec..f278e8d06e 100644
--- a/docs/cordis-tutorial/05-config.md
+++ b/docs/cordis-tutorial/05-config.md
@@ -67,6 +67,33 @@ ValidationError: invalid config:
The plugin's fiber goes to FAILED, and this tutorial's launcher exits with status 1 after printing the error. A plugin should also reject schema-valid config that names an unavailable resource or provider as soon as it can resolve that reference.
+
+## Volatile fields
+
+Use `.volatile()` for a field that the plugin reads during each operation. A change to that field updates its stable reference without remounting the plugin. Read it with `.get()`:
+
+```ts
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/cordis-plugin-loader'
+import Schema from '@deepseek-ai/schemastery'
+
+export const Config = Schema.object({
+ greeting: Schema.string().default('Hello').volatile(),
+})
+
+export function apply(ctx: Context, config: ReturnType) {
+ ctx.on('loader/volatile-update', () => {
+ console.log(config.greeting.get())
+ })
+}
+```
+
+A direct `Config(raw)` call also returns references. An optional field without a default still has a reference; `.get()` returns `undefined` when it is absent. Keep the reference, or capture its value for one operation, including across `await`. Do not retain that value for later operations. Object and array values are detached, recursively frozen snapshots of plain data; functions, class instances, and cycles are rejected.
+
+Loader compares raw config while ignoring schema-declared volatile fields. A volatile-only change is parsed through the plugin’s `internal/config` hook and validated; when every ordinary effective value still matches, Loader commits the new values into the running references and emits one instance-local `loader/volatile-update` with the changed paths as arrays of keys, bypassing the target plugin’s `internal/update`. Equal values do not notify. An invalid candidate is logged and leaves the running references unchanged until the next activation. A changed ordinary effective value, including a changed expression result, and simultaneous ordinary-field changes retain the existing update flow, leaving old references unchanged and unnotified. Group/Include still dispatch child updates. Direct `fiber.update()`, code replacement, and dependency replacement retain their lifecycle; `noSave` still controls direct-update persistence. Notifications use ordinary `emit` without awaiting resource reconfiguration.
+
+Declare volatile fields at fixed object paths, or mark an entire object or array volatile. Independent references inside arrays, dictionaries, union or intersect branches, lazy schemas, transforms, or another volatile value are rejected. Configuration files store ordinary values; `simplify()` unwraps references. Serialized schemas preserve their field types, defaults, roles, and volatile marker for schema-driven forms. Existing settings consumers continue to use their own settings interfaces.
+
## Computed config values
The loader used in this repo supports a `!!js` tag for config values that must be computed at load time:
diff --git a/docs/cordis-tutorial/05-config.zh.md b/docs/cordis-tutorial/05-config.zh.md
index e19dcb3ab0..65e9ea73a5 100644
--- a/docs/cordis-tutorial/05-config.zh.md
+++ b/docs/cordis-tutorial/05-config.zh.md
@@ -67,6 +67,33 @@ ValidationError: invalid config:
插件的 fiber 进入 FAILED 状态,本教程的启动器打印错误后以状态码 1 退出。如果某个插件的配置通过了 schema 验证,但其中指定的资源或提供方不可用,该插件也应当在能解析该引用时立即拒绝。
+
+## Volatile 字段
+
+对于插件在每次操作中读取的字段,可以使用 `.volatile()`。字段变化会更新稳定引用,无需重新挂载插件。通过 `.get()` 读取:
+
+```ts
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/cordis-plugin-loader'
+import Schema from '@deepseek-ai/schemastery'
+
+export const Config = Schema.object({
+ greeting: Schema.string().default('Hello').volatile(),
+})
+
+export function apply(ctx: Context, config: ReturnType) {
+ ctx.on('loader/volatile-update', () => {
+ console.log(config.greeting.get())
+ })
+}
+```
+
+直接调用 `Config(raw)` 也会返回引用。没有默认值的可选字段仍然具有引用;字段缺省时,`.get()` 返回 `undefined`。可以保存引用,也可以在单次操作中保存读取值,包括跨越 `await`,但不能将该值用于后续操作。对象和数组值是脱离输入、递归冻结的普通数据快照;函数、类实例和循环引用会被拒绝。
+
+Loader 比较原始配置时忽略 schema 声明的 volatile 字段。仅 volatile 变化会经目标插件的 `internal/config` 钩子解析并校验;当所有普通字段的有效值仍然一致时,Loader 把新值提交到运行中的引用,并向所属实例发出一次 `loader/volatile-update`,参数是以键数组表示的变化路径,不进入目标插件的 `internal/update`。等值更新不通知。无效候选会记录日志,运行中的引用保持不变,直到下次激活。普通字段有效值变化(包括表达式求值结果变化)以及普通字段同时变化时沿用原更新流程,旧引用不更新也不接收通知。Group/Include 仍分发子条目更新。直接 `fiber.update()`、代码替换和依赖替换保留原有生命周期;`noSave` 仍控制直接更新的持久化。通知使用普通 `emit`,不等待资源重新配置完成。
+
+在固定对象路径上声明 volatile 字段,或将整个对象、数组标记为 volatile。数组、字典、union 或 intersect 分支、lazy、transform 或另一个 volatile 值内部的独立引用会被拒绝。配置文件保存普通值;`simplify()` 会解包引用。序列化 schema 保留字段类型、默认值、角色和 volatile 标记,供基于 schema 的表单使用。现有 settings 消费者继续使用原有 settings 接口。
+
## 计算得到的配置值
本仓库使用的 loader 支持 `!!js` 标签,用于必须在加载时计算的配置值:
diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml
index 75f348ee55..a8617d5fc1 100644
--- a/packages/boot/app-boot/README.i18n.yaml
+++ b/packages/boot/app-boot/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
-README.md: ef98b3fd98829c14dc6614de321cc02837c8eb0c
-README.zh.md: 2e0ce42936701ddd94d82dae5fcfee5d33e326e0
+README.md: 4763d5e0e22560373320b1f6a478e3a71212c4e6
+README.zh.md: 5fc90e3773ce152e8fca78c101753e746e986985
diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md
index ef98b3fd98..4763d5e0e2 100644
--- a/packages/boot/app-boot/README.md
+++ b/packages/boot/app-boot/README.md
@@ -73,7 +73,7 @@ Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifi
### Startup and reload failures
-Profile reconciliation returns diagnostics for unchanged inactive entries without failing an unrelated mutation. A new inactive entry, a changed configuration or fiber, or a changed diagnostic fails reconciliation; removed fibers must still finish disposal. Explicit enablement targets must activate even when their failure predates the operation.
+Profile reconciliation returns diagnostics for unchanged inactive entries without failing an unrelated mutation. A new inactive entry, a changed configuration or fiber, or a changed diagnostic fails reconciliation; removed fibers must still finish disposal. Explicit enablement targets must activate even when their failure predates the operation. Successful reconciliation returns after lifecycle settlement and diagnostic checks; volatile-only entry changes are committed by Loader during the update.
After the Loader settles, app-boot warns when only optional entries are inactive. If an enabled required entry cannot activate, `boot()` rejects with `StartupError` after disposal. An independently owned logger exporter retains warning and error records through asynchronous disposal and is released before `boot()` settles. Its message groups all failed plugins and pending services, marks required entries, and retains original stacks, nested causes, and aggregate members. The CLI prints that message once and saves [full startup diagnostics](../../../apps/cli/reference/README.md#startup-diagnostics) before exiting with code 1; unrelated exceptions retain their normal stack output. In the table, stopping startup means disposing any mounted plugins and exiting nonzero without reporting readiness; continuing keeps successful plugins running. Later configuration HMR does not repeat the required-startup audit and does not roll back the whole update.
diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md
index 2e0ce42936..5fc90e3773 100644
--- a/packages/boot/app-boot/README.zh.md
+++ b/packages/boot/app-boot/README.zh.md
@@ -73,7 +73,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
### 启动与重载失败
-profile 重载返回未变化的已有故障诊断,不让无关修改因此失败。新增未激活条目、配置或 fiber 变化、诊断变化都会使重载失败;被移除的 fiber 仍须完成释放。显式启用的目标必须成功激活,即使它的故障早于本次操作。
+profile 重载返回未变化的已有故障诊断,不让无关修改因此失败。新增未激活条目、配置或 fiber 变化、诊断变化都会使重载失败;被移除的 fiber 仍须完成释放。显式启用的目标必须成功激活,即使它的故障早于本次操作。 成功重载在生命周期结束及诊断检查通过后返回;仅 volatile 的条目变化由 Loader 在更新过程中提交。
Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如果已启用的 required 条目无法激活,`boot()` 会在释放资源后以 `StartupError` 拒绝。独立管理生命周期的 logger exporter 会保留异步资源释放期间的警告和错误记录,并在 `boot()` 结算前释放。其消息分组列出所有失败插件和等待的服务,标记 required 条目,并保留原始堆栈、嵌套原因和聚合错误成员。CLI 仅输出该消息一次,并在保存[完整启动诊断](../../../apps/cli/reference/README.zh.md#startup-diagnostics)后以退出码 1 结束;其他异常保留正常堆栈输出。表中的“终止启动”指释放已挂载插件并以非零码退出,不报告就绪;“继续”指保留成功运行的插件。后续配置 HMR 不会再次执行 required 启动审计,也不会回滚整个更新。
diff --git a/packages/boot/hmr/README.i18n.yaml b/packages/boot/hmr/README.i18n.yaml
index bb50a0f843..2a8a9c45e5 100644
--- a/packages/boot/hmr/README.i18n.yaml
+++ b/packages/boot/hmr/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/boot/hmr/README.md
-README.md: 735b37c41e50c6a1f663cbfa0cde42f7250ec415
-README.zh.md: b0d3196913cb11e434b397350c81ad1bb45dc0c6
+README.md: 9e2c62c739c420ceae3db3fd5ade58d6eeadacec
+README.zh.md: fa926d5998f7f4b784fe2f848b5acc3047b91b77
diff --git a/packages/boot/hmr/README.md b/packages/boot/hmr/README.md
index 735b37c41e..9e2c62c739 100644
--- a/packages/boot/hmr/README.md
+++ b/packages/boot/hmr/README.md
@@ -54,7 +54,7 @@ Chokidar options, including polling, retain their existing meaning. Exact config
Implementation internals — click to expand
-`watchConfig()` registers an awaited configuration handler. `runExclusive()` serializes configuration changes and Loader updates with automatic reloads and rejects nested transactions. Package installation and removal run outside this queue. HMR does not acquire the package writer lock; manifest notifications reload only when the ordered `dsh.profile.bundles` list changes. Profile and home patch changes also trigger recomposition. File events received during a configuration transaction are processed afterward.
+`watchConfig()` registers an awaited configuration handler. `runExclusive()` serializes configuration changes and Loader updates with automatic reloads and rejects nested transactions. Package installation and removal run outside this queue. HMR does not acquire the package writer lock; manifest notifications reload only when the ordered `dsh.profile.bundles` list changes. Profile and home patch changes also trigger recomposition. File events received during a configuration transaction are processed afterward. Include refreshes and profile reconciliation reach plugins through ordinary Loader entry updates; Loader commits volatile-only changes in place.
App-boot owns profile parsing and patch precedence. HMR reads the launcher’s data-only `profileContext`, registers the profile manifest and both user patch watches during initialization, and waits for application readiness before processing changes. Its disposal closes the watchers and cancels reloads waiting for startup. HMR also owns module-cache replacement and reload scheduling. Configuration watchers start outside the active transaction context so later notifications can enter the queue. No invariant companion is published because the queue and watcher registrations have no independent persisted projection.
diff --git a/packages/boot/hmr/README.zh.md b/packages/boot/hmr/README.zh.md
index b0d3196913..fa926d5998 100644
--- a/packages/boot/hmr/README.zh.md
+++ b/packages/boot/hmr/README.zh.md
@@ -54,7 +54,7 @@ Chokidar 选项(包括轮询)保持原有含义。精确配置监听同时
实现细节——点击展开
-`watchConfig()` 注册会被等待的配置处理器。`runExclusive()` 将配置变更、Loader 更新与自动重载串行化,并拒绝嵌套事务。包安装和删除在该队列之外执行。HMR 不获取包操作写锁;manifest 通知仅在有序的 `dsh.profile.bundles` 列表变化时触发重载。profile 与 home patch 变化也会触发重新组合。配置事务期间收到的文件事件在事务结束后处理。
+`watchConfig()` 注册会被等待的配置处理器。`runExclusive()` 将配置变更、Loader 更新与自动重载串行化,并拒绝嵌套事务。包安装和删除在该队列之外执行。HMR 不获取包操作写锁;manifest 通知仅在有序的 `dsh.profile.bundles` 列表变化时触发重载。profile 与 home patch 变化也会触发重新组合。配置事务期间收到的文件事件在事务结束后处理。 Include 刷新和 profile 重载都通过普通的 Loader 条目更新到达插件;仅 volatile 变化由 Loader 就地提交。
App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提供的纯数据 `profileContext`,在初始化时注册 profile manifest 和两份用户 patch 的监听,并等待应用就绪后处理更改。销毁 HMR 时会关闭监听器并取消等待启动的重载。HMR 也负责模块缓存替换和重载调度。配置监听器在当前事务上下文之外启动,使后续通知可以进入队列。不发布 invariant 伴生入口,因为队列和监听注册没有独立的持久投影。
diff --git a/packages/boot/hmr/tests/modules.spec.ts b/packages/boot/hmr/tests/modules.spec.ts
index 6e2a7e12a9..93b96a007c 100644
--- a/packages/boot/hmr/tests/modules.spec.ts
+++ b/packages/boot/hmr/tests/modules.spec.ts
@@ -7,6 +7,7 @@ import NodeModule, { createRequire } from 'node:module'
import { Context, type Plugin } from '@deepseek-ai/cordis'
import Loader, { type ModuleJob, type ModuleLoader } from '@deepseek-ai/cordis-plugin-loader'
import Timer from '@deepseek-ai/cordis-plugin-timer'
+import z from '@deepseek-ai/schemastery'
import { expect, it, onTestFinished, vi } from 'vitest'
import Hmr from '../src/index.ts'
@@ -87,6 +88,31 @@ it.each(['v1', 'v2'] as const)('replaces a %s module and retains the latest Load
expect(mounted.at(-1)).toBe('after:next')
})
+it('replaces volatile references on code HMR after an in-place config update', async () => {
+ const { ctx, module, imports, reload } = await fixture()
+ const Config = z.object({ value: z.string().volatile() })
+ const configs: ReturnType[] = []
+ const before = { Config, apply(_ctx: Context, config: ReturnType) { configs.push(config) } }
+ const after = { Config, apply(_ctx: Context, config: ReturnType) { configs.push(config) } }
+ const job = module('volatile.mjs', before)
+ const entry = ctx.loader.resolve(await ctx.loader.create({ name: job.url, config: { value: 'initial' } }))
+ await ctx.loader.await()
+ await entry.update({ config: { value: 'current' } })
+ await ctx.loader.await()
+ expect(configs).toHaveLength(1)
+ const old = configs[0]!.value
+ expect(old.get()).toBe('current')
+ imports.set(job.url, after)
+ reload.stashed.add(job.url)
+ await reload.partialReload()
+ expect(configs).toHaveLength(2)
+ expect(configs[1]!.value).not.toBe(old)
+ expect(configs[1]!.value.get()).toBe('current')
+ await entry.update({ config: { value: 'after-reload' } })
+ expect(configs[1]!.value.get()).toBe('after-reload')
+ expect(old.get()).toBe('current')
+})
+
it('reactivates a source-replaced consumer after provider and consumer configuration changes', async () => {
const { ctx, module, imports, reload } = await fixture()
const mounted: string[] = []
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5d3e3cf6f4..39acefeee7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -12990,6 +12990,9 @@ importers:
'@deepseek-ai/cosmokit':
specifier: link:../cosmokit
version: link:../cosmokit
+ '@deepseek-ai/schemastery':
+ specifier: link:../schemastery
+ version: link:../schemastery
node-addon-require-builtin:
specifier: ^0.1.6
version: 0.1.6
diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts
index 1b19d386b1..b742bbcc58 100644
--- a/scripts/gen-cordis-catalog.ts
+++ b/scripts/gen-cordis-catalog.ts
@@ -895,19 +895,21 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
],
}],
inheritedEvents: [
- { name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:328' },
- { name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:330' },
- { name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:332' },
- { name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:334' },
- { name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:336' },
- { name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:338' },
- { name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' },
- { name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' },
- { name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' },
- { name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' },
- { name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' },
- { name: 'loader/partial-dispose', summary: 'An entry is being partially disposed on reload.', source: 'vendor/loader/src/index.ts:26' },
- { name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:27' },
+ { name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:331' },
+ { name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:333' },
+ { name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:341' },
+ { name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:343' },
+ { name: 'internal/config', summary: 'Waterfall: a fiber config is being resolved before validation.', source: 'vendor/cordis/src/events.ts:339' },
+ { name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:345' },
+ { name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:347' },
+ { name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:349' },
+ { name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:351' },
+ { name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:25' },
+ { name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:26' },
+ { name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:27' },
+ { name: 'loader/partial-dispose', summary: 'An entry is being partially disposed on reload.', source: 'vendor/loader/src/index.ts:28' },
+ { name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:41' },
+ { name: 'loader/volatile-update', summary: 'Volatile config values committed into the running fiber without a remount; dispatched to the owning fiber only.', source: 'vendor/loader/src/index.ts:34' },
],
inheritedServices: [
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:34' },
diff --git a/scripts/loader-config-diff.spec.ts b/scripts/loader-config-diff.spec.ts
new file mode 100644
index 0000000000..0cd0a3eb50
--- /dev/null
+++ b/scripts/loader-config-diff.spec.ts
@@ -0,0 +1,109 @@
+/** Raw config comparison reads schema metadata without resolving config or committing references. */
+import { expect, it, vi } from 'vitest'
+import z from '@deepseek-ai/schemastery'
+import { equalExceptVolatile as equal } from '../vendor/loader/src/config/diff.ts'
+
+it('ignores volatile fields in frozen raw inputs without running schema callbacks', () => {
+ const validate = vi.fn((value: string) => value.trim())
+ const schema = z.object({ title: z.transform(z.string(), validate).volatile(), fixed: z.string() })
+ Object.freeze(schema.meta)
+ Object.freeze(schema.dict)
+ const previous = Object.freeze({ title: 'old', fixed: 'same' })
+ const next = Object.freeze({ title: { __jsExpr: 'throw new Error("must not run")' }, fixed: 'same' })
+ expect(equal(previous, next, schema)).toBe(true)
+ expect(equal(previous, next, schema)).toBe(true)
+ expect(validate).not.toHaveBeenCalled()
+ expect(previous.title).toBe('old')
+ expect(next.title).toEqual({ __jsExpr: 'throw new Error("must not run")' })
+})
+
+it('ignores absent, nested and whole-object/array/union volatile fields', () => {
+ const schema = z.object({
+ nested: z.object({ value: z.string().volatile() }),
+ block: z.object({ title: z.string() }).volatile(),
+ list: z.array(z.string()).volatile(),
+ choice: z.union([z.string(), z.number()]).volatile(),
+ })
+ const next = { nested: { value: 'new' }, block: { title: 'new' }, list: ['new'], choice: 1 }
+ expect(equal({}, next, schema)).toBe(true)
+ expect(equal(next, {}, schema)).toBe(true)
+ expect(equal('old', 'new', z.string().volatile())).toBe(true)
+ const restored = new z(JSON.parse(JSON.stringify(schema)) as z)
+ expect(equal({}, next, restored)).toBe(true)
+})
+
+it('preserves ordinary and unknown fields, object defaults and parent expressions', () => {
+ const schema = z.object({ nested: z.object({ live: z.string().volatile(), fixed: z.string() }).default({ fixed: 'default' }) })
+ expect(equal({}, { nested: { live: 'new', fixed: 'default' } }, schema)).toBe(true)
+ expect(equal({}, { nested: { live: 'new' } }, schema)).toBe(false)
+ expect(equal({}, { unknown: 'new' }, schema)).toBe(false)
+ expect(equal({ nested: { __jsExpr: 'source.a' } }, { nested: { __jsExpr: 'source.b' } }, schema)).toBe(false)
+ expect(equal({ nested: 'invalid' }, { nested: { live: 'new' } }, schema)).toBe(false)
+ expect(equal({ nested: new Date(0) }, { nested: new Date(1) }, schema)).toBe(false)
+ expect(equal({ nested: [] }, { nested: ['new'] }, schema)).toBe(false)
+})
+
+it('keeps ordinary raw changes even if schema validation would normalize them to equal values', () => {
+ const normalize = vi.fn(() => 'same')
+ const schema = z.object({ fixed: z.transform(z.string(), normalize), live: z.string().volatile() })
+ expect(equal({ fixed: 'first' }, { fixed: 'second' }, schema)).toBe(false)
+ expect(normalize).not.toHaveBeenCalled()
+ const other = { '~standard': { version: 1 as const, vendor: 'other', validate: vi.fn() } }
+ expect(equal('first', 'second', other)).toBe(false)
+ expect(other['~standard'].validate).not.toHaveBeenCalled()
+ expect(equal('first', 'second', undefined)).toBe(false)
+})
+
+it('compares raw values strictly without a schema', () => {
+ expect(equal([undefined], [null], undefined)).toBe(false)
+ expect(equal(undefined, null, undefined)).toBe(false)
+ expect(equal({ fixed: 'same' }, { fixed: 'same' }, undefined)).toBe(true)
+ expect(equal({ list: Array(1) }, { list: ['new'] }, undefined)).toBe(false)
+})
+
+it('compares recursive schemas conservatively and visits shared schema nodes at each path', () => {
+ const recursive = z.object({ value: z.string() }).extra('default', undefined)
+ recursive.set('child', recursive)
+ expect(equal({ value: 'old' }, { value: 'new' }, recursive)).toBe(false)
+ const shared = z.object({ value: z.string().volatile() })
+ const schema = z.object({ first: shared, second: shared })
+ expect(equal({}, { first: { value: 'one' }, second: { value: 'two' } }, schema)).toBe(true)
+})
+
+it('equates absent objects with defaults while preserving ordinary field differences', () => {
+ const schema = z.object({ nested: z.object({ fixed: z.string() }).default({ fixed: 'default' }) })
+ for (const absent of [undefined, null]) {
+ expect(equal(absent, {}, schema)).toBe(true)
+ expect(equal({}, absent, schema)).toBe(true)
+ expect(equal({ nested: absent }, { nested: { fixed: 'default' } }, schema)).toBe(true)
+ }
+ expect(equal({}, { nested: { fixed: 'changed' } }, schema)).toBe(false)
+ expect(equal({}, { nested: {} }, schema)).toBe(false)
+})
+
+it('preserves parent expression edits even when their marker matches a volatile field', () => {
+ const schema = z.object({ __jsExpr: z.string().volatile() })
+ expect(equal({ __jsExpr: 'first' }, { __jsExpr: 'second' }, schema)).toBe(false)
+})
+
+it('detects ordinary sparse array edits in both directions', () => {
+ const schema = z.object({ list: z.array(z.string()), live: z.string().volatile() })
+ const before = { list: Array(1), live: 'before' }
+ const after = { list: ['new'], live: 'after' }
+ expect(equal(before, after, schema)).toBe(false)
+ expect(equal(after, before, schema)).toBe(false)
+})
+
+it('compares unknown fields whose names match Object prototype properties', () => {
+ const schema = z.object({ live: z.string().volatile() })
+ for (const key of ['constructor', 'toString', '__proto__']) {
+ const previous = { [key]: 'first', live: 'old' }
+ expect(equal(previous, { [key]: 'second', live: 'new' }, schema)).toBe(false)
+ expect(equal(previous, { [key]: 'first', live: 'new' }, schema)).toBe(true)
+ }
+})
+
+it('compares metadata-free serialized schema nodes', () => {
+ const schema = new z(JSON.parse('{"type":"object","dict":{"nested":{"type":"object","dict":{"enabled":{"type":"boolean"}}}}}') as z)
+ expect(equal({ nested: { enabled: true } }, { nested: { enabled: false } }, schema)).toBe(false)
+})
diff --git a/scripts/loader-volatile-update.spec.ts b/scripts/loader-volatile-update.spec.ts
new file mode 100644
index 0000000000..16d8f3b0a8
--- /dev/null
+++ b/scripts/loader-volatile-update.spec.ts
@@ -0,0 +1,634 @@
+/** Loader commits volatile config values into running fibers without remounting; ordinary changes keep the update lifecycle. */
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
+import { Context, type Fiber, type Plugin } from '@deepseek-ai/cordis'
+import Loader, { Group } from '@deepseek-ai/cordis-plugin-loader'
+import Include from '@deepseek-ai/cordis-plugin-include'
+import Timer from '@deepseek-ai/cordis-plugin-timer'
+import z from '@deepseek-ai/schemastery'
+import { Config } from './volatile-config.fixture.ts'
+import Hmr from '@deepseek-ai/dsh-hmr'
+import { boot, reconcileProfilePatches } from '@deepseek-ai/dsh-app-boot'
+
+function context() {
+ const ctx = new Context()
+ onTestFinished(() => ctx.fiber.dispose())
+ return ctx
+}
+
+async function runtimeContext() {
+ const ctx = context()
+ await ctx.plugin(Loader)
+ return ctx
+}
+
+async function mountEntry(ctx: Context, plugin: Plugin, config: unknown = {}): Promise {
+ const name = `consumer-${Object.keys(ctx.loader.builtins).length}`
+ ctx.loader.builtins[name] = plugin
+ const id = await ctx.loader.create({ name: `cordis:${name}`, config })
+ const fiber = ctx.loader.resolve(id).fiber!
+ await fiber.await()
+ return fiber
+}
+
+async function update(fiber: Fiber, config: unknown): Promise {
+ await fiber.entry!.update({ config })
+ await fiber.await()
+}
+
+describe('volatile Loader updates', () => {
+ it('updates a whole-config reference and reports the root path', async () => {
+ const ctx = await runtimeContext()
+ const Config = z.object({ title: z.string() }).volatile()
+ const events = vi.fn()
+ const fiber = await mountEntry(ctx, { Config, apply(owner: Context) {
+ owner.on('loader/volatile-update', events)
+ } }, { title: 'first' })
+ const reference = fiber.config as ReturnType
+ await update(fiber, { title: 'second' })
+ expect(fiber.config).toBe(reference)
+ expect(reference.get()).toEqual({ title: 'second' })
+ expect(events).toHaveBeenCalledExactlyOnceWith([[]])
+ })
+ it('commits all references before one local event without remounting', async () => {
+ const ctx = await runtimeContext()
+ const configs: ReturnType[] = []
+ const observed: unknown[] = []
+ const mount = vi.fn(function (owner: Context, config: ReturnType) {
+ configs.push(config)
+ owner.on('loader/volatile-update', paths => observed.push({ paths, title: config.title.get(), count: config.nested.count.get() }))
+ })
+ const first = await mountEntry(ctx, { Config, apply: mount }, {})
+ await mountEntry(ctx, { Config, apply: mount }, {})
+ const initial = configs[0]!
+ const title = initial.title
+ await update(first, { title: 'hello' })
+ expect(observed).toEqual([])
+ await update(first, { title: 'next', nested: { count: 2 } })
+ await first.await()
+ expect(mount).toHaveBeenCalledTimes(2)
+ expect(first.config).toBe(initial)
+ expect(initial.title).toBe(title)
+ expect(configs[1]!.title.get()).toBe('hello')
+ expect(observed).toEqual([{ paths: [['title'], ['nested', 'count']], title: 'next', count: 2 }])
+ await update(first, { title: 'next', nested: { count: 2 } })
+ expect(observed).toHaveLength(1)
+ await update(first, { optional: 'added' })
+ expect(initial.optional.get()).toBe('added')
+ await update(first, {})
+ expect(initial.optional.get()).toBeUndefined()
+ })
+
+ it('keeps references on invalid updates and replaces them on mixed updates', async () => {
+ const ctx = await runtimeContext()
+ const notifications = vi.fn()
+ const disposed = vi.fn()
+ const configs: ReturnType[] = []
+ const plugin = { Config, apply(owner: Context, config: ReturnType) {
+ configs.push(config)
+ owner.on('loader/volatile-update', notifications)
+ owner.effect(() => disposed)
+ } }
+ const fiber = await mountEntry(ctx, plugin, {})
+ const before = configs[0]!
+ await update(fiber, { title: 'invalid candidate', nested: { count: -1 } })
+ expect(before.title.get()).toBe('hello')
+ expect(notifications).not.toHaveBeenCalled()
+ await update(fiber, { title: 'new', fixed: 'restart' })
+ await fiber.await()
+ expect(configs).toHaveLength(2)
+ expect(disposed).toHaveBeenCalledOnce()
+ expect(notifications).not.toHaveBeenCalled()
+ expect(before.title.get()).toBe('hello')
+ expect(configs[1]!.title.get()).toBe('new')
+ expect(configs[1]!.title).not.toBe(before.title)
+ })
+
+ it('bypasses custom update hooks for volatile changes and retains them for mixed changes', async () => {
+ const ctx = await runtimeContext()
+ const hooks: string[] = []
+ const globalHook = vi.fn((_config: unknown, _noSave: boolean, next: () => void) => { next() })
+ ctx.on('internal/update', globalHook, { global: true })
+ const fiber = await mountEntry(ctx, { Config, apply(owner: Context) {
+ owner.on('internal/update', (config: ReturnType) => {
+ hooks.push(config.title.get())
+ })
+ } }, {})
+ const initial = fiber.config as ReturnType
+ await update(fiber, { title: 'accepted' })
+ expect(fiber.config).toBe(initial)
+ expect(initial.title.get()).toBe('accepted')
+ await update(fiber, { title: 'accepted' })
+ expect(hooks).toEqual([])
+ expect(globalHook).not.toHaveBeenCalled()
+ await update(fiber, { title: 'custom', fixed: 'changed' })
+ expect(hooks).toEqual(['custom'])
+ expect(initial.title.get()).toBe('accepted')
+ })
+
+ it('compares ordinary dates and arrays and retains the existing nonvolatile restart behavior', async () => {
+ const ctx = await runtimeContext()
+ const schema = z.object({ live: z.string().volatile(), date: z.date(), list: z.array(z.string()) })
+ const apply = vi.fn(function () {})
+ const fiber = await mountEntry(ctx, { Config: schema, apply }, { live: 'a', date: '2026-01-01', list: ['a'] })
+ await update(fiber, { live: 'b', date: '2026-01-01', list: ['a'] })
+ await fiber.await()
+ expect(apply).toHaveBeenCalledOnce()
+ await update(fiber, { live: 'c', date: '2026-01-02', list: ['a'] })
+ await fiber.await()
+ expect(apply).toHaveBeenCalledTimes(2)
+ await update(fiber, { live: 'd', date: '2026-01-02', list: ['b'] })
+ await fiber.await()
+ expect(apply).toHaveBeenCalledTimes(3)
+ const ordinary = await mountEntry(ctx, { Config: z.object({ value: z.string() }), apply() { apply() } }, { value: 'same' })
+ ordinary.update({ value: 'same' })
+ await ordinary.await()
+ expect(apply).toHaveBeenCalledTimes(5)
+ })
+
+ it('resolves pending updates and creates fresh references after dependency replacement', async () => {
+ const ctx = await runtimeContext()
+ const configs: ReturnType[] = []
+ const consumer = await mountEntry(ctx, { Config, inject: ['volatile-test-provider'], apply(_ctx: Context, config: ReturnType) {
+ configs.push(config)
+ } }, {})
+ await update(consumer, { title: 'pending' })
+ const provider = await ctx.plugin({ apply(owner: Context) { owner.provide('volatile-test-provider', {}) } })
+ await consumer.await()
+ expect(configs[0]!.title.get()).toBe('pending')
+ await update(consumer, { title: 'live' })
+ await provider.dispose()
+ const before = configs[0]!.title
+ await ctx.plugin({ apply(owner: Context) { owner.provide('volatile-test-provider', {}) } })
+ await consumer.await()
+ expect(configs).toHaveLength(2)
+ expect(configs[1]!.title.get()).toBe('live')
+ expect(configs[1]!.title).not.toBe(before)
+ })
+
+ it('remounts for changed class instances and cyclic ordinary config', async () => {
+ const ctx = await runtimeContext()
+ const schema = z.object({
+ live: z.string().volatile(),
+ url: z.transform(z.string(), value => new URL(value)),
+ data: z.transform(z.string(), () => { const data: Record = {}; data.self = data; return data }),
+ })
+ const apply = vi.fn(function () {})
+ const oldUrl = 'https://a.example/'
+ const newUrl = 'https://b.example/'
+ const fiber = await mountEntry(ctx, { Config: schema, apply }, { live: 'a', url: oldUrl })
+ const before = fiber.config as ReturnType
+ await update(fiber, { live: 'b', url: newUrl })
+ await fiber.await()
+ expect(apply).toHaveBeenCalledTimes(2)
+ expect((fiber.config as ReturnType).url.href).toBe(newUrl)
+ expect(before.live.get()).toBe('a')
+ await update(fiber, { live: 'c', url: newUrl, data: 'first' })
+ await update(fiber, { live: 'd', url: newUrl, data: 'second' })
+ expect(apply).toHaveBeenCalledTimes(4)
+ })
+
+ it('distinguishes undefined from null inside volatile arrays', async () => {
+ const ctx = await runtimeContext()
+ const schema = z.object({ values: z.array(z.any()).volatile() })
+ const events = vi.fn()
+ const fiber = await mountEntry(ctx, { Config: schema, apply(owner: Context) {
+ owner.on('loader/volatile-update', events)
+ } }, { values: [undefined] })
+ await update(fiber, { values: [null] })
+ expect((fiber.config as ReturnType).values.get()).toEqual([null])
+ expect(events).toHaveBeenCalledExactlyOnceWith([['values']])
+ })
+})
+
+it('reloads real YAML and persists plain values independently of custom update hooks', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'dsh-volatile-'))
+ const ctx = new Context()
+ onTestFinished(async () => { await ctx.fiber.dispose(); await rm(root, { recursive: true, force: true }) })
+ ctx.baseUrl = pathToFileURL(root).href + '/'
+ await ctx.plugin(Loader)
+ const configs: ReturnType[] = []
+ ctx.loader.builtins.include = Include
+ ctx.loader.builtins.group = Group
+ const updates = vi.fn()
+ const consumer = { Config, apply(owner: Context, config: ReturnType) {
+ configs.push(config)
+ owner.on('internal/update', updates)
+ } }
+ ctx.loader.builtins['volatile-test'] = consumer
+ const filename = join(root, 'cordis.yml')
+ await writeFile(filename, '- id: group\n name: cordis:group\n group: true\n config:\n - id: consumer\n name: cordis:volatile-test\n config:\n title: initial\n')
+ const rootId = await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(filename).href } })
+ await ctx.loader.await()
+ const include = ctx.loader.resolve(rootId).subtree as Include
+ const group = include.store.group!.fiber!
+ const groupUid = group.uid
+ const entry = [...ctx.loader.entries()].find(entry => entry.options.id === 'consumer')!
+ expect(configs[0]!.title.get()).toBe('initial')
+ await ctx.plugin(Timer)
+ // An exact-file config watch mirrors the HMR root-watch Include path, which packages/boot/hmr covers with a mocked watcher.
+ await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
+ const stopWatching = await ctx.hmr.watchConfig(filename, async () => {
+ await include.refresh()
+ await ctx.loader.await()
+ })
+ await writeFile(filename, '- id: group\n name: cordis:group\n group: true\n config:\n - id: consumer\n name: cordis:volatile-test\n config:\n title: file-update\n')
+ // The config watcher stabilizes writes for two seconds before publishing a change.
+ await vi.waitFor(() => { expect(configs[0]!.title.get()).toBe('file-update') }, { timeout: 10_000 })
+ await stopWatching()
+ expect(configs).toHaveLength(1)
+ expect(include.store.group!.fiber).toBe(group)
+ expect(group.uid).toBe(groupUid)
+ expect(configs[0]!.title.get()).toBe('file-update')
+ entry.fiber!.update({ title: 'program-update', group: { list: ['item'] } })
+ expect(entry.options.config).toEqual({ title: 'program-update', group: { list: ['item'] } })
+ entry.fiber!.update({ title: 'ephemeral' }, true)
+ expect(entry.options.config).toEqual({ title: 'program-update', group: { list: ['item'] } })
+ await ctx.fiber.dispose()
+ const saved = await readFile(filename, 'utf8')
+ expect(saved).toContain('program-update')
+ expect(saved).not.toContain('get:')
+ expect(saved).not.toContain('ephemeral')
+ expect(updates).toHaveBeenCalledTimes(2)
+ const restored = context()
+ restored.baseUrl = ctx.baseUrl
+ await restored.plugin(Loader)
+ restored.loader.builtins.include = Include
+ restored.loader.builtins.group = Group
+ restored.loader.builtins['volatile-test'] = consumer
+ await restored.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(filename).href } })
+ await restored.loader.await()
+ expect(configs).toHaveLength(2)
+ expect(configs[1]!.title.get()).toBe('program-update')
+ expect(configs[1]!.group.get()).toEqual({ list: ['item'] })
+ await restored.fiber.dispose()
+})
+
+it('updates volatile children through Group and Include custom updates and saves ordinary config', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'dsh-volatile-tree-'))
+ const ctx = new Context()
+ onTestFinished(async () => { await ctx.fiber.dispose(); await rm(root, { recursive: true, force: true }) })
+ ctx.baseUrl = pathToFileURL(root).href + '/'
+ await ctx.plugin(Loader)
+ ctx.loader.builtins.include = Include
+ ctx.loader.builtins.group = Group
+ const configs: ReturnType[] = []
+ const notifications = vi.fn()
+ const childUpdates = vi.fn()
+ ctx.loader.builtins['volatile-test'] = { Config, apply(owner: Context, config: ReturnType) {
+ configs.push(config)
+ owner.on('loader/volatile-update', notifications)
+ owner.on('internal/update', childUpdates)
+ } }
+ const child = (title: string) => ({ id: 'consumer', name: 'cordis:volatile-test', config: { title } })
+ const filename = join(root, 'tree.json')
+ const includePath = pathToFileURL(filename).href
+ await writeFile(filename, JSON.stringify([{ id: 'group', name: 'cordis:group', group: true, config: [child('initial')] }]))
+ const rootId = await ctx.loader.create({ name: 'cordis:include', config: { path: includePath } })
+ await ctx.loader.await()
+ const includeEntry = ctx.loader.resolve(rootId)
+ const include = includeEntry.subtree as Include
+ const group = include.store.group!
+ const consumer = include.store.consumer!
+ const originalInclude = includeEntry.fiber
+ const originalGroup = group.fiber
+ const originalConsumer = consumer.fiber
+ const initial = configs[0]!
+
+ group.fiber!.update([child('group-update')])
+ await ctx.loader.await()
+ expect(initial.title.get()).toBe('group-update')
+ expect(group.options.config).toEqual([child('group-update')])
+ includeEntry.fiber!.update({ path: includePath, patches: [{ id: 'consumer', config: { title: 'include-update' } }] })
+ await ctx.loader.await()
+ expect(initial.title.get()).toBe('include-update')
+ expect(includeEntry.fiber).toBe(originalInclude)
+ expect(group.fiber).toBe(originalGroup)
+ expect(consumer.fiber).toBe(originalConsumer)
+ expect(consumer.fiber!.config).toBe(initial)
+ expect(configs).toHaveLength(1)
+ expect(notifications).toHaveBeenCalledTimes(2)
+ expect(childUpdates).not.toHaveBeenCalled()
+
+ const ordinary = await ctx.loader.create({ name: 'cordis:volatile-test', config: {} })
+ const entry = ctx.loader.resolve(ordinary)
+ entry.fiber!.update({ title: 'custom-update', fixed: 'ordinary' })
+ expect(childUpdates).toHaveBeenCalledOnce()
+ expect(entry.options.config).toEqual({ title: 'custom-update', fixed: 'ordinary' })
+ expect((entry.fiber!.config as ReturnType).title.get()).toBe('hello')
+ entry.fiber!.update({ title: 'ephemeral', fixed: 'ordinary' }, true)
+ expect(childUpdates).toHaveBeenCalledTimes(2)
+ expect(entry.options.config).toEqual({ title: 'custom-update', fixed: 'ordinary' })
+ expect(() => { entry.fiber!.update({ nested: { count: -1 } }) }).toThrow()
+ expect(childUpdates).toHaveBeenCalledTimes(2)
+ expect(entry.options.config).toEqual({ title: 'custom-update', fixed: 'ordinary' })
+})
+
+it('commits volatile edits in place and retains direct Fiber updates', async () => {
+ const ctx = await runtimeContext()
+ const notifications = vi.fn()
+ const apply = vi.fn(function (owner: Context) { owner.on('loader/volatile-update', notifications) })
+ const fiber = await mountEntry(ctx, { Config, apply }, {})
+ const original = fiber.config as ReturnType
+ await ctx.loader.create({ name: 'cordis:disabled', disabled: true })
+ await update(fiber, { title: 'enabled' })
+ expect(apply).toHaveBeenCalledOnce()
+ expect(fiber.config).toBe(original)
+ expect(original.title.get()).toBe('enabled')
+ expect(notifications).toHaveBeenCalledExactlyOnceWith([['title']])
+ await update(fiber, { title: 'invalid', nested: { count: -1 } })
+ expect(original.title.get()).toBe('enabled')
+ await update(fiber, { title: 'mixed', fixed: 'changed' })
+ expect(apply).toHaveBeenCalledTimes(2)
+ expect(original.title.get()).toBe('enabled')
+ const current = fiber.config as ReturnType
+ expect(current.title.get()).toBe('mixed')
+
+ const hook = vi.fn((_config: unknown, _noSave: boolean, next: () => void) => { next() })
+ fiber.ctx.on('internal/update', hook)
+ fiber.update({ title: 'direct' })
+ await fiber.await()
+ expect(hook).toHaveBeenCalledOnce()
+ expect(apply).toHaveBeenCalledTimes(3)
+ expect(current.title.get()).toBe('mixed')
+ expect(notifications).toHaveBeenCalledOnce()
+})
+
+it('parses a volatile-only update through the config hook without patching the entry context', async () => {
+ const ctx = await runtimeContext()
+ const trace: string[] = []
+ const trim = vi.fn((value: string) => { trace.push('validate'); return value.trim() })
+ const Config = z.object({ block: z.object({ title: z.transform(z.string(), trim, true) }).volatile() })
+ const fiber = await mountEntry(ctx, { Config, apply(owner: Context) {
+ owner.on('loader/volatile-update', () => { trace.push('notify') })
+ } }, { block: { title: 'initial' } })
+ const current = fiber.config as ReturnType
+ const resolveHook = vi.fn((_raw: unknown, next: () => unknown) => next())
+ fiber.ctx.on('internal/config', resolveHook)
+ const contextPatch = vi.fn((_entry: unknown, next: () => void) => { next() })
+ ctx.on('loader/patch-context', contextPatch)
+ trim.mockClear()
+ trace.length = 0
+ const raw = { block: { title: ' changed ' } }
+ await fiber.entry!.update({ config: raw })
+ expect(contextPatch).not.toHaveBeenCalled()
+ expect(trim).toHaveBeenCalledExactlyOnceWith(' changed ')
+ expect(resolveHook).toHaveBeenCalledOnce()
+ expect(trace).toEqual(['validate', 'notify'])
+ expect(current.block.get()).toEqual({ title: 'changed' })
+ expect(fiber._config).toBe(raw)
+})
+
+it.each([true, false])('keeps old references untouched when isolation replaces a dependency (available: %s)', async (available) => {
+ const ctx = await runtimeContext()
+ const configs: ReturnType[] = []
+ const notifications = vi.fn()
+ ctx.loader.builtins.provider = { apply(owner: Context) { owner.provide('volatile-isolated-service', {}) } }
+ ctx.loader.builtins.consumer = {
+ inject: ['volatile-isolated-service'], Config,
+ apply(owner: Context, config: ReturnType) {
+ configs.push(config)
+ owner.on('loader/volatile-update', notifications)
+ },
+ }
+ const provider = (realm: string) => ctx.loader.create({
+ name: 'cordis:provider', isolate: { 'volatile-isolated-service': realm },
+ })
+ await provider('a')
+ if (available) await provider('b')
+ const id = await ctx.loader.create({
+ name: 'cordis:consumer', isolate: { 'volatile-isolated-service': 'a' }, config: { title: 'old' },
+ })
+ await ctx.loader.await()
+ const original = configs[0]!
+ await ctx.loader.resolve(id).update({ isolate: { 'volatile-isolated-service': 'b' }, config: { title: 'new' } })
+ await ctx.loader.await()
+ expect(configs).toHaveLength(available ? 2 : 1)
+ expect(original.title.get()).toBe('old')
+ expect(notifications).not.toHaveBeenCalled()
+ if (!available) {
+ await provider('b')
+ await ctx.loader.await()
+ }
+ expect(configs).toHaveLength(2)
+ expect(configs[1]!.title.get()).toBe('new')
+})
+
+it.each(['group', 'include'] as const)('applies profile patches to a %s subtree without HMR and keeps mixed updates on the normal lifecycle', async (kind) => {
+ const root = await mkdtemp(join(tmpdir(), 'dsh-volatile-profile-'))
+ onTestFinished(() => rm(root, { recursive: true, force: true }))
+ const configs: ReturnType[] = []
+ const events = vi.fn()
+ const dispose = vi.fn()
+ const consumer = { Config, apply(owner: Context, config: ReturnType) {
+ configs.push(config)
+ owner.on('loader/volatile-update', events)
+ owner.effect(() => dispose)
+ } }
+ const child = (title: string, fixed = 'fixed') => ({ id: 'consumer', name: 'cordis:consumer', config: { title, fixed } })
+ const nested = join(root, 'nested.json')
+ await writeFile(nested, JSON.stringify([child('initial')]))
+ const carrierConfig = (title: string, fixed = 'fixed') => kind === 'group'
+ ? [child(title, fixed)]
+ : { path: pathToFileURL(nested).href, patches: [{ id: 'consumer', config: { title, fixed } }] }
+ const filename = join(root, 'cordis.yml')
+ await writeFile(filename, JSON.stringify([{ id: 'parent', name: `cordis:${kind}`, group: kind === 'group', config: carrierConfig('initial') }]))
+ const ctx = await boot('volatile-test', filename, undefined, async (ctx) => {
+ ctx.loader.builtins.consumer = consumer
+ })
+ onTestFinished(() => ctx.fiber.dispose())
+ expect(ctx.get('hmr')).toBeUndefined()
+ const parent = [...ctx.loader.entries()].find(entry => entry.options.id === 'parent')!
+ const entry = [...ctx.loader.entries()].find(entry => entry.options.id === 'consumer')!
+ const parentFiber = parent.fiber
+ const childFiber = entry.fiber
+ const original = configs[0]!
+ const reference = original.title
+ const patch = (title: string, fixed = 'fixed') => [{ id: 'parent', config: carrierConfig(title, fixed) }]
+ await reconcileProfilePatches(ctx, patch('first'), 'volatile-test')
+ await reconcileProfilePatches(ctx, patch('second'), 'volatile-test')
+ expect(parent.fiber).toBe(parentFiber)
+ expect(entry.fiber).toBe(childFiber)
+ expect(entry.fiber!.config).toBe(original)
+ expect(original.title).toBe(reference)
+ expect(reference.get()).toBe('second')
+ expect(configs).toHaveLength(1)
+ expect(dispose).not.toHaveBeenCalled()
+ expect(events).toHaveBeenCalledTimes(2)
+ expect(events).toHaveBeenLastCalledWith([['title']])
+ await reconcileProfilePatches(ctx, patch('second'), 'volatile-test')
+ expect(events).toHaveBeenCalledTimes(2)
+ await reconcileProfilePatches(ctx, patch('mixed', 'changed'), 'volatile-test')
+ expect(parent.fiber).toBe(parentFiber)
+ expect(configs).toHaveLength(2)
+ expect(dispose).toHaveBeenCalledOnce()
+ expect(reference.get()).toBe('second')
+ expect(configs[1]!.title.get()).toBe('mixed')
+ expect(events).toHaveBeenCalledTimes(2)
+})
+
+it('logs an invalid candidate and keeps its references while other entries commit', async () => {
+ const ctx = await runtimeContext()
+ const events = vi.fn()
+ const plugin = { Config, apply(owner: Context) { owner.on('loader/volatile-update', events) } }
+ const invalid = await mountEntry(ctx, plugin)
+ const healthy = await mountEntry(ctx, plugin)
+ const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+ onTestFinished(() => { warning.mockRestore() })
+ await invalid.entry!.update({ config: { title: 'invalid', nested: { count: -1 } } })
+ await healthy.entry!.update({ config: { title: 'healthy' } })
+ expect(warning).toHaveBeenCalledTimes(2)
+ expect((invalid.config as ReturnType).title.get()).toBe('hello')
+ expect(invalid._config).toEqual({ title: 'invalid', nested: { count: -1 } })
+ expect((healthy.config as ReturnType).title.get()).toBe('healthy')
+ expect(events).toHaveBeenCalledExactlyOnceWith([['title']])
+})
+
+it.each([false, true])('retains raw expression edits for dependency restoration (volatile: %s)', async (volatile) => {
+ const ctx = await runtimeContext()
+ const schema = z.object({ value: volatile ? z.string().volatile() : z.string() })
+ const values: unknown[] = []
+ const provide = (a: string, b: string) => ctx.plugin({ apply(owner: Context) { owner.provide('volatile-expression-source', { a, b }) } })
+ const provider = await provide('same', 'same')
+ const fiber = await mountEntry(ctx, { Config: schema, inject: ['volatile-expression-source'], apply(_owner: Context, config: ReturnType) {
+ values.push(typeof config.value === 'string' ? config.value : config.value.get())
+ } }, { value: { __jsExpr: "ctx.get('volatile-expression-source').a" } })
+ const raw = { value: { __jsExpr: "ctx.get('volatile-expression-source').b" } }
+ await fiber.entry!.update({ config: raw })
+ await fiber.await()
+ expect(values).toEqual(volatile ? ['same'] : ['same', 'same'])
+ expect(fiber._config).toBe(raw)
+ await provider.dispose()
+ await provide('old-route', 'new-route')
+ await fiber.await()
+ expect(values).toEqual(volatile ? ['same', 'new-route'] : ['same', 'same', 'new-route'])
+})
+
+it('remounts when changed expressions resolve to ordinary values different from the live config', async () => {
+ const ctx = await runtimeContext()
+ const source = { a: 'initial', b: 'initial' }
+ ctx.provide('volatile-expression-source', source)
+ const configs: ReturnType[] = []
+ const events = vi.fn()
+ const fiber = await mountEntry(ctx, { Config, apply(owner: Context, config: ReturnType) {
+ configs.push(config)
+ owner.on('loader/volatile-update', events)
+ } }, { fixed: { __jsExpr: "ctx.get('volatile-expression-source').a" }, title: 'old' })
+ source.a = source.b = 'changed'
+ await update(fiber, { fixed: { __jsExpr: "ctx.get('volatile-expression-source').b" }, title: 'new' })
+ expect(configs).toHaveLength(2)
+ expect(configs[0]!.title.get()).toBe('old')
+ expect(configs[1]!.fixed).toBe('changed')
+ expect(configs[1]!.title.get()).toBe('new')
+ expect(events).not.toHaveBeenCalled()
+})
+
+it('compares ordinary URL transforms by value and remounts when an expression result changes', async () => {
+ const ctx = await runtimeContext()
+ const source = { value: 'initial' }
+ ctx.provide('volatile-transform-source', source)
+ const schema = z.object({
+ url: z.transform(z.string(), value => new URL(value), true),
+ fixed: z.string(),
+ live: z.string().volatile(),
+ })
+ const events = vi.fn()
+ const mount = vi.fn(function (owner: Context) { owner.on('loader/volatile-update', events) })
+ const fixed = { __jsExpr: "ctx.get('volatile-transform-source').value" }
+ const raw = { url: 'https://example.com/', fixed, live: 'old' }
+ const fiber = await mountEntry(ctx, { Config: schema, apply: mount }, raw)
+ const current = fiber.config as ReturnType
+ await update(fiber, { ...raw, live: 'new' })
+ expect(mount).toHaveBeenCalledOnce()
+ expect(fiber.config).toBe(current)
+ expect(current.url.href).toBe(raw.url)
+ expect(current.fixed).toBe('initial')
+ expect(current.live.get()).toBe('new')
+ expect(events).toHaveBeenCalledExactlyOnceWith([['live']])
+ source.value = 'changed'
+ await update(fiber, { ...raw, live: 'remounted' })
+ expect(mount).toHaveBeenCalledTimes(2)
+ expect(current.fixed).toBe('initial')
+ expect(current.live.get()).toBe('new')
+ expect(events).toHaveBeenCalledOnce()
+ const replaced = fiber.config as ReturnType
+ expect(replaced.fixed).toBe('changed')
+ expect(replaced.live.get()).toBe('remounted')
+})
+
+it('remounts when an ordinary parent expression removes a live reference path', async () => {
+ const ctx = await runtimeContext()
+ const source: { block: { value: string } | undefined } = { block: { value: 'old' } }
+ ctx.provide('volatile-path-source', source)
+ const schema = z.object({ block: z.object({ value: z.string().volatile() }).extra('default', undefined), live: z.string().volatile() })
+ const events = vi.fn()
+ const mount = vi.fn(function (owner: Context) { owner.on('loader/volatile-update', events) })
+ const raw = { block: { __jsExpr: "ctx.get('volatile-path-source').block" }, live: 'old' }
+ const fiber = await mountEntry(ctx, { Config: schema, apply: mount }, raw)
+ const current = fiber.config as ReturnType
+ source.block = undefined
+ await update(fiber, { ...raw, live: 'new' })
+ expect(mount).toHaveBeenCalledTimes(2)
+ expect(current.block.value.get()).toBe('old')
+ expect(events).not.toHaveBeenCalled()
+ expect((fiber.config as ReturnType).block).toBeUndefined()
+})
+
+it('keeps an ordinary plugin mounted when an absent object becomes its explicit default', async () => {
+ const ctx = await runtimeContext()
+ const Config = z.object({ nested: z.object({ fixed: z.string() }).default({ fixed: 'default' }) })
+ const mount = vi.fn()
+ const fiber = await mountEntry(ctx, { Config, apply: mount }, {})
+ const config: unknown = fiber.config
+ await update(fiber, { nested: { fixed: 'default' } })
+ expect(fiber.config).toBe(config)
+ expect(mount).toHaveBeenCalledTimes(1)
+ await update(fiber, { nested: { fixed: 'changed' } })
+ expect(mount).toHaveBeenCalledTimes(2)
+ expect(fiber.config).toEqual({ nested: { fixed: 'changed' } })
+})
+
+it('keeps a noSave volatile value as the pending raw config across a no-op entry update', async () => {
+ const ctx = await runtimeContext()
+ const fiber = await mountEntry(ctx, { Config, apply() {} }, { title: 'saved' })
+ fiber.update({ title: 'ephemeral' }, true)
+ await fiber.await()
+ expect((fiber.config as ReturnType).title.get()).toBe('ephemeral')
+ expect(fiber.entry!.options.config).toEqual({ title: 'saved' })
+ await fiber.entry!.update({ config: { title: 'saved' } })
+ expect(fiber._config).toEqual({ title: 'ephemeral' })
+ expect((fiber.config as ReturnType).title.get()).toBe('ephemeral')
+})
+
+it('isolates a failing notification listener and dispatches with a Context receiver', async () => {
+ const ctx = await runtimeContext()
+ const receivers: unknown[] = []
+ const fiber = await mountEntry(ctx, { Config, apply(owner: Context) {
+ owner.on('loader/volatile-update', function (this: Context) { receivers.push(this.fiber); throw new Error('listener boom') })
+ } }, {})
+ const other = await mountEntry(ctx, { Config, apply() {} }, {})
+ const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+ onTestFinished(() => { warning.mockRestore() })
+ await update(fiber, { title: 'committed' })
+ expect((fiber.config as ReturnType).title.get()).toBe('committed')
+ expect(receivers).toEqual([fiber])
+ expect(warning).toHaveBeenCalledWith(expect.objectContaining({ message: 'listener boom' }))
+ await update(other, { title: 'unaffected' })
+ expect((other.config as ReturnType).title.get()).toBe('unaffected')
+})
+
+it('remounts instead of committing when an ordinary field parses into a fresh class instance', async () => {
+ const ctx = await runtimeContext()
+ class Handle { constructor(public readonly name: string) {} }
+ const schema = z.object({ handle: z.transform(z.string(), value => new Handle(value)), live: z.string().volatile() })
+ const mount = vi.fn()
+ const fiber = await mountEntry(ctx, { Config: schema, apply: mount }, { handle: 'h', live: 'old' })
+ const before = fiber.config as ReturnType
+ await update(fiber, { handle: 'h', live: 'new' })
+ expect(mount).toHaveBeenCalledTimes(2)
+ expect(before.live.get()).toBe('old')
+ expect((fiber.config as ReturnType).live.get()).toBe('new')
+})
diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts
index 63d887a098..dd281965df 100644
--- a/scripts/rescope-vendor.ts
+++ b/scripts/rescope-vendor.ts
@@ -78,6 +78,8 @@ interface GenericSkip {
const GENERIC_SKIPS: readonly GenericSkip[] = [
// `Symbol.for('schemastery')` and the `vendor:` metadata field are upstream identifiers.
{ file: 'vendor/schemastery/src/index.ts', upstream: ['schemastery'] },
+ // Narrows a Standard Schema by the same upstream `vendor:` identifier.
+ { file: 'vendor/loader/src/config/diff.ts', upstream: ['schemastery'] },
// Asserts the vendored-manifest table, which gains an upstream-name column.
{ file: 'scripts/gen-third-party-notices.spec.ts', upstream: RENAMES.map(rename => rename.upstream) },
// `cordis` is also an agent-preset id — the directory name under
@@ -172,6 +174,13 @@ const POSTCONDITIONS: readonly PostCondition[] = [
* quote a neighbouring line the generic pass would rewrite.
*/
const EXACT_EDITS: readonly ExactEdit[] = [
+ {
+ id: 'loader-diff-schemastery-import',
+ file: 'vendor/loader/src/config/diff.ts',
+ find: "import type Schema from 'schemastery'",
+ replace: "import type Schema from '@deepseek-ai/schemastery'",
+ expect: 1,
+ },
{
id: 'cordis-walk-merge-head',
file: 'scripts/cordis-walk.ts',
diff --git a/scripts/volatile-config.fixture.ts b/scripts/volatile-config.fixture.ts
new file mode 100644
index 0000000000..a10df387fd
--- /dev/null
+++ b/scripts/volatile-config.fixture.ts
@@ -0,0 +1,11 @@
+/** Shared volatile schema fixture for the schema and Loader volatile specs. */
+import z from '@deepseek-ai/schemastery'
+
+/** Mixed ordinary, volatile, nested and whole-object volatile fields. */
+export const Config = z.object({
+ fixed: z.string().default('fixed'),
+ title: z.string().default('hello').volatile(),
+ optional: z.string().volatile(),
+ nested: z.object({ count: z.number().min(0).volatile().default(1) }).default({}),
+ group: z.object({ list: z.array(z.string()) }).default({ list: [] }).volatile(),
+})
diff --git a/scripts/volatile-config.spec.ts b/scripts/volatile-config.spec.ts
new file mode 100644
index 0000000000..75d2a5f1f6
--- /dev/null
+++ b/scripts/volatile-config.spec.ts
@@ -0,0 +1,151 @@
+/** Schemastery and Cordis config reference behavior through direct and Loader updates. */
+import { describe, expect, expectTypeOf, it } from 'vitest'
+import { type Volatile } from '@deepseek-ai/cordis'
+import z from '@deepseek-ai/schemastery'
+import { Config } from './volatile-config.fixture.ts'
+import { createVolatile, deepEqual, volatileEntries } from '@deepseek-ai/cosmokit'
+import { redactSecrets } from '../packages/settings/settings/src/redact.ts'
+
+describe('volatile schemas', () => {
+ it('parses references with defaults and absent values while preserving schema metadata', () => {
+ const parsed = Config({})
+ expect(parsed.title.get()).toBe('hello')
+ expect(parsed.optional.get()).toBeUndefined()
+ expect(parsed.nested.count.get()).toBe(1)
+ expectTypeOf(parsed.title).toEqualTypeOf>()
+ expectTypeOf(parsed.optional).toEqualTypeOf>()
+ expectTypeOf(parsed.nested.count).toEqualTypeOf>()
+ expectTypeOf(z.string().volatile().description('Live text').default('text')().get()).toEqualTypeOf()
+ expectTypeOf(z.string().required().volatile()('text').get()).toEqualTypeOf()
+ expect(Config.dict?.title?.type).toBe('string')
+ expect(Config.dict?.title?.meta.default).toBe('hello')
+ expect(Config.simplify(parsed)).toBeNull()
+ const reconstructed = new z(JSON.parse(JSON.stringify(Config.toJSON())) as z)
+ expect((reconstructed({ title: 'changed' }) as ReturnType).title.get()).toBe('changed')
+ expect(reconstructed.simplify(reconstructed({ title: 'changed' }))).toEqual({ title: 'changed' })
+ expect(z.string().volatile().required()('present').get()).toBe('present')
+ expect(() => z.string().volatile().required()()).toThrow('missing required value')
+ expect(() => Config({ nested: { count: -1 } })).toThrow()
+ expect(Config({ nested: { count: -1 } }, { autofix: true }).nested.count.get()).toBe(1)
+ })
+
+ it('copies and freezes data snapshots without freezing the input', () => {
+ const input = { group: { list: ['a'] } }
+ const parsed = Config(input)
+ input.group.list.push('b')
+ expect(parsed.group.get()).toEqual({ list: ['a'] })
+ expect(Object.isFrozen(parsed.group.get())).toBe(true)
+ expect(Object.isFrozen(parsed.group.get().list)).toBe(true)
+ expect(() => (parsed.group.get().list as string[]).push('c')).toThrow()
+ expect(Object.keys(parsed.group)).toEqual(['get'])
+ expect(() => z.any().volatile()(new Date())).toThrow('plain objects')
+ const cyclic: Record = {}
+ cyclic.self = cyclic
+ expect(() => z.any().volatile()(cyclic)).toThrow('cycles')
+ })
+
+ it.each([
+ ['array', () => z.object({ items: z.array(z.string().volatile()) }), 'items'],
+ ['dict', () => z.object({ items: z.dict(z.string().volatile()) }), 'items'],
+ ['union', () => z.union([z.object({ title: z.string().volatile() }), z.string()]), 'title'],
+ ['tuple', () => z.object({ items: z.tuple([z.string().volatile()]) }), 'items'],
+ ['intersect', () => z.intersect([z.object({ title: z.string().volatile() })]), 'title'],
+ ['transform', () => z.transform(z.object({ title: z.string().volatile() }), value => value), 'title'],
+ ['nested', () => z.object({ block: z.object({ title: z.string().volatile() }).volatile() }), 'block'],
+ ])('rejects %s placements even when input omits the field', (_name, schema, path) => {
+ expect(() => schema()({})).toThrow(path)
+ })
+
+ it('rejects repeat wrapping and supports an entire array or union as a value', () => {
+ expect(() => z.string().volatile().volatile()).toThrow('already wrapped')
+ expect(z.array(z.string()).volatile()(['a']).get()).toEqual(['a'])
+ expect(z.union([z.string(), z.number()]).volatile()(1).get()).toBe(1)
+ const schema = z.string().volatile()
+ expect(schema('first')).not.toBe(schema('first'))
+ })
+
+ it('retains ordinary fields and secret traversal in serialized schemas', () => {
+ const schema = z.object({ block: z.object({ apiKey: z.string().role('secret'), title: z.string() }).volatile() })
+ const restored = new z(JSON.parse(JSON.stringify(schema.toJSON())) as z)
+ expect(restored.dict?.block?.type).toBe('object')
+ expect(restored.dict?.block?.dict?.apiKey?.meta.role).toBe('secret')
+ expect(redactSecrets(restored as z, { block: { apiKey: 'secret', title: 'public' } }).value)
+ .toEqual({ block: { title: 'public' } })
+ })
+
+ it('accepts metadata-free serialized nodes and checks their nested volatile declarations', () => {
+ const schema = new z<{ enabled: boolean }>(JSON.parse('{"type":"object","dict":{"enabled":{"type":"boolean"}}}') as z<{ enabled: boolean }>)
+ expect(schema({ enabled: true })).toEqual({ enabled: true })
+ const dynamic = new z(JSON.parse(
+ '{"type":"object","dict":{"items":{"type":"array","inner":{"type":"string","meta":{"volatile":true}}}}}',
+ ) as z)
+ expect(() => dynamic({ items: [] })).toThrow('items')
+ })
+
+ it('resolves recursive lazy schemas only for the supplied data', () => {
+ interface Tree { name: string; children?: Tree[] }
+ function tree(): z {
+ return z.object({ name: z.string(), children: z.array(z.lazy(tree)).default([]) })
+ }
+ expect(tree()({ name: 'root' })).toEqual({ name: 'root', children: [] })
+ expect(tree()({ name: 'root', children: [{ name: 'leaf' }] }))
+ .toEqual({ name: 'root', children: [{ name: 'leaf', children: [] }] })
+ const schema = z.object({ lazy: z.lazy(() => z.object({ live: z.string().volatile() })) })
+ expect(() => schema({ lazy: { live: 'value' } })).toThrow('lazy')
+ })
+})
+
+describe('volatile comparison semantics', () => {
+ it('ignores reference contents while retaining ordinary differences', () => {
+ const first = Config({ title: 'a' })
+ const second = Config({ title: 'b' })
+ expect(deepEqual(first, second, true)).toBe(true)
+ expect(deepEqual(first, Config({ fixed: 'changed' }), true)).toBe(false)
+ expect(deepEqual(first.title, 'a', true)).toBe(false)
+ expect(deepEqual('a', first.title, true)).toBe(false)
+ expect(deepEqual([undefined], [null], true)).toBe(false)
+ expect(deepEqual([undefined], [null])).toBe(true)
+ expect(deepEqual(new Date(0), new Date(0), true)).toBe(true)
+ expect(deepEqual(/a/g, /a/i, true)).toBe(false)
+ expect(deepEqual(new URL('https://a.example'), new URL('https://b.example'), true)).toBe(false)
+ expect(deepEqual(new URL('https://EXAMPLE.com:443/'), new URL('https://example.com'), true)).toBe(true)
+ expect(deepEqual(new URL('https://example.com'), {}, true)).toBe(false)
+ expect(deepEqual({}, new URL('https://example.com'), true)).toBe(false)
+ expect(deepEqual(new Uint8Array([1]).buffer, new Uint8Array([2]).buffer, true)).toBe(false)
+ const firstCycle: Record = {}; firstCycle.self = firstCycle
+ const secondCycle: Record = {}; secondCycle.self = secondCycle
+ expect(deepEqual(firstCycle, secondCycle, true)).toBe(false)
+ })
+})
+
+it('enumerates references through shared config containers without descending into snapshots or cycles', () => {
+ const ref = createVolatile({ nested: 'value' })
+ const shared = Object.assign(Object.create(null), { live: ref }) as Record
+ const config: Record = { list: [shared, shared], opaque: new URL('https://example.com'), plain: 'value' }
+ config.self = config
+ expect(volatileEntries(config)).toEqual([
+ { path: ['list', '0', 'live'], ref },
+ { path: ['list', '1', 'live'], ref },
+ ])
+ expect(volatileEntries(ref)).toEqual([{ path: [], ref }])
+})
+
+it('compares sparse array slots as undefined in both directions', () => {
+ for (const strict of [true, false]) {
+ expect(deepEqual(Array(1), [undefined], strict)).toBe(true)
+ expect(deepEqual([undefined], Array(1), strict)).toBe(true)
+ expect(deepEqual(Array(1), ['new'], strict)).toBe(false)
+ expect(deepEqual(['new'], Array(1), strict)).toBe(false)
+ expect(deepEqual(Array(1), [], strict)).toBe(false)
+ expect(deepEqual([], Array(1), strict)).toBe(false)
+ }
+ expect(deepEqual(Array(1), [null], true)).toBe(false)
+ expect(deepEqual([null], Array(1), true)).toBe(false)
+})
+
+it('reports unsupported volatile values as validation issues with their path', () => {
+ const schema = z.object({ block: z.object({ when: z.date().volatile() }) })
+ expect(() => schema({ block: { when: new Date(0) } })).toThrow(z.ValidationError)
+ expect(() => schema({ block: { when: new Date(0) } })).toThrow('$.block.when')
+ expect(schema({ block: {} }).block.when.get()).toBeUndefined()
+})
diff --git a/vendor/README.md b/vendor/README.md
index 4c9e1832ae..f6e31b6617 100644
--- a/vendor/README.md
+++ b/vendor/README.md
@@ -54,6 +54,8 @@ Keep this log exhaustive — every divergence from upstream must be listed.
21. **`cordis/src/logger.ts` exporter disposal**: each disposer retains its registration id, so removing an earlier exporter cannot delete a later console or telemetry exporter. Covered by startup collector cleanup in `packages/boot/app-boot/tests/app-boot.spec.ts` and disabled-feedback output in `packages/session/session-telemetry-otel/tests/loader-composition.e2e.ts`.
+22. **Volatile config across `cosmokit`, `schemastery`, `cordis`, and `loader`**: Cosmokit supplies immutable references and framework-owned commits; `deepEqual` treats two references as equal, propagates strict comparison into arrays, compares every array slot with holes treated as undefined, handles cycles conservatively, compares URLs by normalized href, and compares other opaque objects by identity in strict mode. Schemastery adds `.volatile()`, optional/default inference, placement validation without eager lazy-schema expansion (a lazy inner is validated once when built), metadata-free serialized node compatibility, serialized metadata, reference-aware simplification, unsupported volatile values reported as `ValidationError` with their path, and `.default()` accepting the raw input type `Partial` because a volatile field makes the output type carry references. Loader's save path calls `simplify` with its schema receiver (`unparse.call`), an independent fix for the unbound-`this` call. Cordis exports consumer types without changing Fiber lifecycle. Loader compares raw options strictly in `Entry.update`; for a config-only change on an active fiber it calls the pure `equalExceptVolatile` function in `config/diff.ts`, which narrows the Standard Schema to Schemastery's `Schema` type (a new type-only `@deepseek-ai/schemastery` dependency of `loader`) and recursively skips fixed volatile paths using schema metadata without executing config hooks or validation. Missing or null object nodes compare using their declared defaults, including nodes without volatile descendants; schema backedges retain raw comparison. Entry passes its actual schema and preserves ordinary context and custom-update lifecycles. For a volatile-only change, `Entry._commitVolatile` re-parses the raw config through the fiber's `internal/config` hook and `resolveConfig`, compares ordinary effective values, commits references and emits the instance-local `loader/volatile-update` declared in `loader/src/index.ts`; a changed ordinary effective value falls back to the ordinary remount, and an invalid candidate is logged while the raw config stays retained for the next activation. Cosmokit provides reference enumeration and commits, and `simplify` is called with its schema receiver. The package READMEs document these additions; schema tests live in `scripts/volatile-config.spec.ts`, Loader runtime and real-file tests in `scripts/loader-volatile-update.spec.ts`, and code replacement tests in the HMR module suite.
+
## Sync procedure
To update a vendored package from upstream:
diff --git a/vendor/cordis/README.md b/vendor/cordis/README.md
index 538809ddfb..d0181dbf76 100644
--- a/vendor/cordis/README.md
+++ b/vendor/cordis/README.md
@@ -99,3 +99,7 @@ yarn lint
The monorepo uses Yakumo to build and test all packages. Most examples in the
docs use public APIs from `cordis`; loader examples additionally use
`@cordisjs/plugin-loader` and `@cordisjs/plugin-include`.
+
+## Volatile configuration
+
+Schemas may return `Volatile` references, whose values are read through `.get()`. Cordis exports the consumer types; Loader commits volatile-only changes into the running references without restarting and notifies the owning fiber through `loader/volatile-update`. Direct `fiber.update()` retains its existing update waterfall and default restart.
diff --git a/vendor/cordis/src/index.ts b/vendor/cordis/src/index.ts
index d0814213e0..5c27008b44 100644
--- a/vendor/cordis/src/index.ts
+++ b/vendor/cordis/src/index.ts
@@ -12,3 +12,5 @@ export * from './registry.ts'
export * from './service.ts'
/** Shared internal helpers used by context, services, and plugin fibers. */
export * from './utils.ts'
+/** Readonly references returned by volatile config schemas. */
+export type { Volatile, VolatileSnapshot } from '@deepseek-ai/cosmokit'
diff --git a/vendor/cosmokit/README.md b/vendor/cosmokit/README.md
index 5398a146c2..7cdc1d1704 100644
--- a/vendor/cosmokit/README.md
+++ b/vendor/cosmokit/README.md
@@ -22,3 +22,9 @@ import cosmokit from 'cosmokit'
```ts
import cosmokit from 'npm:cosmokit@latest'
```
+
+## Config references
+
+`Volatile` exposes `get()` for immutable data snapshots. `createVolatile()` copies and freezes validated data; framework-owned `updateVolatile()` transfers a candidate snapshot into an existing reference. Framework-owned `volatileEntries()` enumerates references and their paths through plain config containers, stopping at snapshots and opaque objects. The shared symbol protocol recognizes references across ESM/CJS copies. Plugin consumers import the type from Cordis and retain the reference rather than caching values between operations.
+
+`deepEqual()` treats two volatile references as equal regardless of their snapshots. Strict comparisons distinguish null from undefined recursively, compare URLs by normalized `href` and other opaque objects by identity, and treat distinct cyclic structures as unequal. Array comparison visits every index and treats holes as undefined. Snapshot-change detection must compare `.get()` results separately.
diff --git a/vendor/cosmokit/src/index.ts b/vendor/cosmokit/src/index.ts
index 9fe48de069..b6b7144168 100644
--- a/vendor/cosmokit/src/index.ts
+++ b/vendor/cosmokit/src/index.ts
@@ -8,3 +8,5 @@ export * from './misc.ts'
export * from './string.ts'
/** Time constants, parsing, and formatting helpers. */
export * from './time.ts'
+/** Immutable config references shared by schema validators and runtimes. */
+export * from './volatile.ts'
diff --git a/vendor/cosmokit/src/types.ts b/vendor/cosmokit/src/types.ts
index 499a46273a..0aab4420c1 100644
--- a/vendor/cosmokit/src/types.ts
+++ b/vendor/cosmokit/src/types.ts
@@ -1,4 +1,5 @@
import { isNullable } from './misc.ts'
+import { isVolatile } from './volatile.ts'
type GlobalConstructorNames = keyof {
[K in keyof typeof globalThis as typeof globalThis[K] extends abstract new (...args: any) => any ? K : never]: K
@@ -114,29 +115,54 @@ export function clone(source: any, refs = new Map()) {
return result
}
-/** Deeply compare arrays, dates, regexps, buffers, and plain object fields. */
+/**
+ * Compare values recursively, treating two volatile references as equal regardless of value.
+ * Strict comparison distinguishes null/undefined, treats opaque objects by identity,
+ * compares URLs by normalized href, treats array holes as undefined, and considers distinct cyclic structures unequal.
+ * @param a - first value.
+ * @param b - second value.
+ * @param strict - whether to require strict data equality outside volatile references.
+ * @returns whether the values compare equal.
+ */
export function deepEqual(a: any, b: any, strict?: boolean): boolean {
- if (a === b) return true
- if (!strict && isNullable(a) && isNullable(b)) return true
- if (typeof a !== typeof b) return false
- if (typeof a !== 'object') return false
- if (!a || !b) return false
+ const ancestors = new Set