Просмотр исходного кода

fix(boot): own the rows a config override sets, queue recompositions, and let records follow the configuration

Second review round on the contained-group composition:

- Ownership scanned only `insert` rows, so an external bundle could set a
  built-in row's id as a child through `{ id: own, config: [...] }` and the
  Loader re-parented that row under the bundle while `originOf` still named
  the built-in layer. The rows an id-targeted patch sets as a group's config
  now count as the layer's own: an external bundle that sets an id another
  layer owns is left out whole, a built-in layer that takes another layer's
  id fails loud, and a layer restating its own child is not a repeat.
- `recompose` read the committed profile before awaiting the update, so a
  watcher firing while a bundle was being enabled composed the tree before
  the enable and applied it after; the plugin vanished while `layers` kept
  listing it. Recompositions now queue, each reading what the previous one
  committed, and a rejection stays its own caller's outcome.
- A patch that replaced a built-in target's config removed the wrapper the
  bundle had put there, yet later inserts still targeted the detached
  wrapper and vanished. The composition forgets the wrapper on such an
  override and creates a new one under the same id.
- Generated wrapper ids bypassed the duplicate check, so a bundle declaring
  a row spelled like a wrapper produced two nodes with one id. Declared and
  generated ids now go through one registration.
- Failure records outlived their rows when a bundle update dropped a failed
  row without unmounting the group, or when a pending row sat in a plain
  nested group whose id the record named. A contained group now drops the
  records of rows its configuration no longer names after each update, and
  the audit records every row under the contained group that isolates it.
- `applyEntryPatches` assigned override values by reference, so applying a
  patch list carried a later insert's rows back into the patch object; the
  vendored include now clones object-valued overrides as it does inserts.
Yichen Jiang 2 недель назад
Родитель
Сommit
aaf58eb5bf

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.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 .agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.md
-2026-09-04-external-bundles-as-contained-groups.md: 3319394d56630eb90dbce18f54483e22ad8577a5
-2026-09-04-external-bundles-as-contained-groups.zh.md: f439f96f01827ae95ebe2cbe7f94a1acfe833a27
+2026-09-04-external-bundles-as-contained-groups.md: 023fc1efb803348b054e0c0118ba50c4564fc9c1
+2026-09-04-external-bundles-as-contained-groups.zh.md: c86edb796f42e09052e05349cee0cb9bba19911d

+ 4 - 4
.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.md

@@ -10,17 +10,17 @@ A bundle installed with `dsh plugin add` mounted its rows exactly like the insta
 
 ## Decision
 
-**Every external bundle is one group.** The profile launcher classifies each layer by provenance: a bundle that is a pnpm dependency of the profile is `external`, a template bundle or one the profile lists under `dsh.profile.firstParty` is `builtin`. `composeExternalLayer` renders a `runtime`-stage external layer as one `cordis:contained-group` entry, `bundle/<package>`, inserted empty, followed by the bundle's patches in the order written: each root insert is re-targeted into that group, every insert into one built-in group lands in one contained wrapper group nested inside the target, and id-targeted patches pass through, reported as overrides when they address rows the bundle did not insert. Keeping the written order is what lets a patch that replaces a group's config and then appends to it mean the same thing under both stages. `/` rather than `:` in the group id because `:` is the Loader's nested-id separator.
+**Every external bundle is one group.** The profile launcher classifies each layer by provenance: a bundle that is a pnpm dependency of the profile is `external`, a template bundle or one the profile lists under `dsh.profile.firstParty` is `builtin`. `composeExternalLayer` renders a `runtime`-stage external layer as one `cordis:contained-group` entry, `bundle/<package>`, inserted empty, followed by the bundle's patches in the order written: each root insert is re-targeted into that group, every insert into one built-in group lands in one contained wrapper group nested inside the target (a patch that replaces the target's config removes that wrapper, so the next insert creates a new one under the same id), and id-targeted patches pass through, reported as overrides when they address rows the bundle did not introduce. The rows an id-targeted patch sets as a group's config count as the bundle's own: they mount as children exactly like inserted ones. Every id the layer introduces, declared or generated, goes through one registration, so a row that spells the group's or a wrapper's id is a repeat. Keeping the written order is what lets a patch that replaces a group's config and then appends to it mean the same thing under both stages. `/` rather than `:` in the group id because `:` is the Loader's nested-id separator.
 
-**Row ids are owned, never rewritten.** `composeProfileStack` decides ownership before anything mounts: built-in and boot-staged layers claim their ids first and a duplicate among them fails the boot; a contained bundle that declares an id another layer owns, or declares one of its own ids twice, is left out whole; a user-layer insert of a taken id is dropped. The rows left out are the composition's conflicts, each carrying its message: printed on stderr at boot, held by `ProfileRuntime` as part of the committed composition, and shown per package in the plugin list. They never enter `pluginFailures`, whose records name rows that reached the Loader. Boot, live recomposition, and `--dump-config` compose through the one function, which renders each contained layer once and returns the patches, the owner of every id, and the conflicts together.
+**Row ids are owned, never rewritten.** `composeProfileStack` decides ownership before anything mounts: a layer introduces the rows it inserts and the rows its config overrides set as a group's children; built-in and boot-staged layers claim their ids first and a duplicate among them fails the boot, while a config override restating a row the same layer declared is that layer keeping its own child; a contained bundle that declares an id another layer owns, or declares one of its own ids twice, is left out whole; a user-layer insert of a taken id is dropped, and a user override's rows are the user's own to restate. The rows left out are the composition's conflicts, each carrying its message: printed on stderr at boot, held by `ProfileRuntime` as part of the committed composition, and shown per package in the plugin list. They never enter `pluginFailures`, whose records name rows that reached the Loader. Boot, live recomposition, and `--dump-config` compose through the one function, which renders each contained layer once and returns the patches, the owner of every id, and the conflicts together.
 
-**The contained group isolates row failures.** `ContainedGroup extends Group` overrides `create()`, the one per-row step the transactional `update()` awaits: a rejected row is recorded on the root's `pluginFailures` registry — tree-wide id, declared row id, module, group, stage parsed from the Loader's wrapper, message — and the group activates without it. When the group unmounts — its bundle disabled or uninstalled — it drops its rows' records, so no failure outlives the composition that produced it. `assertEntriesActivated` exempts contained rows (a failed or pending one becomes a record) and keeps the fatal path for built-in rows. One fail-safe closes the corner case where isolation would hide a broken core: a built-in row left pending while any bundle is isolated still fails the boot, and the diagnostic names the isolated bundles and the `stage: boot` escape.
+**The contained group isolates row failures.** `ContainedGroup extends Group` overrides `create()`, the one per-row step the transactional `update()` awaits: a rejected row is recorded on the root's `pluginFailures` registry — tree-wide id, declared row id, module, group, stage parsed from the Loader's wrapper, message — and the group activates without it. A record names the contained group that isolates the row, whatever plain groups sit between. When the group updates, the records of rows its configuration dropped go; when it unmounts — its bundle disabled or uninstalled — all its rows' records go with it, so no failure outlives the composition that produced it. `assertEntriesActivated` exempts contained rows (a failed or pending one becomes a record) and keeps the fatal path for built-in rows. One fail-safe closes the corner case where isolation would hide a broken core: a built-in row left pending while any bundle is isolated still fails the boot, and the diagnostic names the isolated bundles and the `stage: boot` escape.
 
 **`stage: boot` is the explicit opt-out.** A bundle whose rows provide a service built-in rows inject declares `dsh.bundle.stage: boot` in its manifest, or the deployer sets `dsh.profile.stages` in the profile manifest, which wins; such a layer mounts unwrapped with fatal semantics. An unknown stage value fails profile loading.
 
 **Installed and enabled are two facts.** `reconcileInstalledBundles` no longer appends every bundle-declaring dependency to `dsh.profile.bundles` unconditionally; `autoEnable` keeps the CLI's install-and-enable semantics, and `enableBundle`/`disableBundle` are the manifest operations a plugin manager calls. `dependencies` records the install, `bundles` the enabled layers.
 
-**Provenance and recomposition are one launcher service.** `ProfileRuntime` (`ctx.profileRuntime`) holds the committed composition — the profile, the owner of every row id (`originOf`), and the conflicts — reads which rows the user patch files disable with a literal `disabled: true`, and is the one entry point that recomposes the tree: it composes a candidate, applies it through the root include, and publishes the candidate only once the include accepted it, so a rejected update leaves the facts describing the tree still running. The patch watchers call its `recompose` instead of composing themselves. The plugin inventory reads it and the failure registry to serve `trust`, `package`, `disabledBy`, and `failure` per row, listing the conflicts and the rows the registry alone knows.
+**Provenance and recomposition are one launcher service.** `ProfileRuntime` (`ctx.profileRuntime`) holds the committed composition — the profile, the owner of every row id (`originOf`), and the conflicts — reads which rows the user patch files disable with a literal `disabled: true`, and is the one entry point that recomposes the tree: it composes a candidate, applies it through the root include, and publishes the candidate only once the include accepted it, so a rejected update leaves the facts describing the tree still running; recompositions run one at a time, each reading what the previous one committed, so a watcher firing while a bundle is being enabled recomposes the enabled tree instead of the one before it. The patch watchers call its `recompose` instead of composing themselves. The plugin inventory reads it and the failure registry to serve `trust`, `package`, `disabledBy`, and `failure` per row, listing the conflicts and the rows the registry alone knows.
 
 ## Alternatives considered
 

+ 4 - 4
.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.zh.md

@@ -10,17 +10,17 @@ Status: implemented
 
 ## 决定
 
-**每个外部组合包就是一个组。** profile launcher 按来源给每一层分类:作为 profile 的 pnpm 依赖存在的组合包是 `external`,模板组合包或 profile 在 `dsh.profile.firstParty` 下列出的是 `builtin`。`composeExternalLayer` 把 `runtime` 阶段的外部层渲染成一个 `cordis:contained-group` 条目 `bundle/<package>`,先空着插入,随后按书写顺序跟着组合包自己的 patch:根级插入改为插进这个组,插入同一个内置组的行全部落进目标组内嵌套的同一个受控包装组,按 id 定位的 patch 原样通过,指向它没有插入的行时报告为覆盖。保持书写顺序,才能让"先替换某个组的 config 再向它追加"这样的 patch 在两种 stage 下含义一致。组 id 用 `/` 而不是 `:`,因为 `:` 是 Loader 的嵌套 id 分隔符。
+**每个外部组合包就是一个组。** profile launcher 按来源给每一层分类:作为 profile 的 pnpm 依赖存在的组合包是 `external`,模板组合包或 profile 在 `dsh.profile.firstParty` 下列出的是 `builtin`。`composeExternalLayer` 把 `runtime` 阶段的外部层渲染成一个 `cordis:contained-group` 条目 `bundle/<package>`,先空着插入,随后按书写顺序跟着组合包自己的 patch:根级插入改为插进这个组,插入同一个内置组的行全部落进目标组内嵌套的同一个受控包装组(替换目标组 config 的 patch 会把这个包装组一并清掉,之后的插入会在同一个 id 下新建一个),按 id 定位的 patch 原样通过,指向它没有引入的行时报告为覆盖。按 id 定位的 patch 设成某个组 config 的行算作组合包自己引入的行:它们和插入的行一样作为子行挂载。这一层引入的每个 id,无论声明的还是生成的,都经过同一次登记,因此一行写成组或包装组的 id 就是重复。保持书写顺序,才能让"先替换某个组的 config 再向它追加"这样的 patch 在两种 stage 下含义一致。组 id 用 `/` 而不是 `:`,因为 `:` 是 Loader 的嵌套 id 分隔符。
 
-**行 id 有归属,不改写。** `composeProfileStack` 在任何行挂载之前判定归属:内置层与 boot 阶段的层先占有 id,它们之间重复即启动失败;受控组合包声明了别的层已占有的 id、或把自己的某个 id 声明了两次时整层排除;用户层插入已被占用的 id 时该行丢弃。被排除的行就是这次组合的冲突,每条自带消息:启动时打到 stderr,由 `ProfileRuntime` 作为已提交组合的一部分持有,在插件列表里按包显示。它们从不进入 `pluginFailures`,那里的记录只指真正到达 Loader 的行。启动、运行时重组与 `--dump-config` 走同一个函数,它把每个受控层只渲染一次,并一并返回 patch、每个 id 的归属与冲突。
+**行 id 有归属,不改写。** `composeProfileStack` 在任何行挂载之前判定归属:一层引入的行包括它插入的行和它的 config 覆盖设为某个组子行的行;内置层与 boot 阶段的层先占有 id,它们之间重复即启动失败,而 config 覆盖重述同一层已声明的行只是这一层保留自己的子行;受控组合包声明了别的层已占有的 id、或把自己的某个 id 声明了两次时整层排除;用户层插入已被占用的 id 时该行丢弃,用户覆盖里的行由用户自己重述。被排除的行就是这次组合的冲突,每条自带消息:启动时打到 stderr,由 `ProfileRuntime` 作为已提交组合的一部分持有,在插件列表里按包显示。它们从不进入 `pluginFailures`,那里的记录只指真正到达 Loader 的行。启动、运行时重组与 `--dump-config` 走同一个函数,它把每个受控层只渲染一次,并一并返回 patch、每个 id 的归属与冲突。
 
-**受控组隔离行的失败。** `ContainedGroup extends Group` 覆盖 `create()`——这是事务性 `update()` 逐行等待的那一步:被拒的行记录到根上的 `pluginFailures` 注册表——树内 id、声明的行 id、模块、组、从 Loader 包装信息解析出的阶段、消息——组在没有它的情况下激活。组卸载时——它的组合包被停用或卸载——会丢掉自己各行的记录,因此没有失败会比产生它的组合活得更久。`assertEntriesActivated` 豁免受控行(失败或 pending 的行变成一条记录),内置行保留致命路径。一条兜底规则封住"隔离反而藏起核心已坏"的 corner case:只要有组合包被隔离,而某个内置行停在 pending,启动仍然失败,诊断点名被隔离的组合包以及 `stage: boot` 这条出路。
+**受控组隔离行的失败。** `ContainedGroup extends Group` 覆盖 `create()`——这是事务性 `update()` 逐行等待的那一步:被拒的行记录到根上的 `pluginFailures` 注册表——树内 id、声明的行 id、模块、组、从 Loader 包装信息解析出的阶段、消息——组在没有它的情况下激活。记录写明隔离该行的受控组,中间隔着多少普通组都一样。组更新时,配置里不再有的行的记录随之丢掉;组卸载时——它的组合包被停用或卸载——它所有行的记录一并丢掉,因此没有失败会比产生它的组合活得更久。`assertEntriesActivated` 豁免受控行(失败或 pending 的行变成一条记录),内置行保留致命路径。一条兜底规则封住"隔离反而藏起核心已坏"的 corner case:只要有组合包被隔离,而某个内置行停在 pending,启动仍然失败,诊断点名被隔离的组合包以及 `stage: boot` 这条出路。
 
 **`stage: boot` 是显式的退出隔离。** 若组合包的行提供内置行所注入的服务,作者在 manifest 里声明 `dsh.bundle.stage: boot`,或部署者在 profile manifest 里设置 `dsh.profile.stages`,后者优先;这样的层不包组、按致命语义挂载。未知的 stage 值让 profile 加载失败。
 
 **安装与启用是两件事。** `reconcileInstalledBundles` 不再无条件把每个声明了组合包的依赖追加进 `dsh.profile.bundles`;`autoEnable` 保留 CLI 装即启用的语义,`enableBundle`/`disableBundle` 是插件管理器调用的 manifest 操作。`dependencies` 记录安装,`bundles` 记录已启用的层。
 
-**来源与重组是同一个 launcher 服务。** `ProfileRuntime`(`ctx.profileRuntime`)持有已提交的组合——profile、每个行 id 的归属(`originOf`)与冲突——读取用户 patch 文件用字面量 `disabled: true` 停用了哪些行,并且是重组整棵树的唯一入口:它先组合候选结果,经根 include 应用,只有 include 接受之后才发布候选结果,因此被拒的更新留下的事实仍然描述正在运行的树。patch 监视器调用它的 `recompose`,不再自己组合。插件清单读取它与失败注册表,为每一行提供 `trust`、`package`、`disabledBy` 与 `failure`,并列出冲突以及只有注册表知道的行。
+**来源与重组是同一个 launcher 服务。** `ProfileRuntime`(`ctx.profileRuntime`)持有已提交的组合——profile、每个行 id 的归属(`originOf`)与冲突——读取用户 patch 文件用字面量 `disabled: true` 停用了哪些行,并且是重组整棵树的唯一入口:它先组合候选结果,经根 include 应用,只有 include 接受之后才发布候选结果,因此被拒的更新留下的事实仍然描述正在运行的树;重组一次只跑一个,后一次读取前一次提交的结果,所以启用组合包期间触发的监视器重组的是启用后的树而不是之前那棵。patch 监视器调用它的 `recompose`,不再自己组合。插件清单读取它与失败注册表,为每一行提供 `trust`、`package`、`disabledBy` 与 `failure`,并列出冲突以及只有注册表知道的行。
 
 ## 考虑过的替代方案
 

+ 2 - 2
docs/subsystems/core.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/subsystems/core.md
-core.md: 9a8633a4e62b87f98629df95a96aa5b1910f50e9
-core.zh.md: f204532ed2dcb862587c23892af37c50684b0483
+core.md: 44975507f4a31aae49b644593a4dcc74864df629
+core.zh.md: b91f236cf4d65928e5806d8d30d75c23d7b5f703

+ 3 - 0
docs/subsystems/core.md

@@ -870,6 +870,9 @@ userDisabledRowIds(): Set<string>
  * its ownership, and its conflicts become the committed composition only
  * once the update holds; until then, and after a rejection, `current`,
  * `layers`, `originOf`, and `conflicts` keep describing the running tree.
+ * Calls queue: one that arrives while another is in flight starts after it
+ * settled and reads what it committed. A rejection is that call's outcome
+ * alone and does not stop the ones behind it.
  * @param options - `reloadBundles` re-reads the profile manifest first, so a
  * bundle enabled or installed since boot joins the stack.
  * @throws when the root include is not mounted, or the Loader rejected the update.

+ 3 - 0
docs/subsystems/core.zh.md

@@ -880,6 +880,9 @@ userDisabledRowIds(): Set<string>
  * its ownership, and its conflicts become the committed composition only
  * once the update holds; until then, and after a rejection, `current`,
  * `layers`, `originOf`, and `conflicts` keep describing the running tree.
+ * Calls queue: one that arrives while another is in flight starts after it
+ * settled and reads what it committed. A rejection is that call's outcome
+ * alone and does not stop the ones behind it.
  * @param options - `reloadBundles` re-reads the profile manifest first, so a
  * bundle enabled or installed since boot joins the stack.
  * @throws when the root include is not mounted, or the Loader rejected the update.

+ 2 - 2
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: ee322da49b92b6ad7bd201c33665a2e29e2a4cd9
-README.zh.md: 44019585d345e02dfcfcd31113a171a292eef22a
+README.md: e3ff5b06c095bdee9fdd26505ed2b63d0402a679
+README.zh.md: 62c37b1b35c113228b10bee688ecd8b248b5f26a

+ 3 - 3
packages/boot/app-boot/README.md

@@ -58,9 +58,9 @@ Profiles with `patchReload: live` watch both user patch files: a valid edit reco
 
 Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative `./` or `../` paths to file URLs within `insert` rows and their nested groups; existing-entry name assertions and replacement `config` values remain literal.
 
-A bundle you installed with `dsh plugin` is an **external** bundle: its rows mount under one contained group named `bundle/<package>` with the ids its patch declares, and a row that fails to start is isolated and recorded instead of stopping the process — the group and its other rows stay up, and the plugin list shows the failure. Template bundles are built in and keep failing loud. A bundle that provides a service built-in rows inject must mount like a built-in one: its author declares `dsh.bundle.stage: boot` in `package.json`, or you set `dsh.profile.stages` in the profile manifest, which wins. Even without that, an isolated failure that leaves a built-in row waiting for a service still stops the boot and names the isolated bundle. Two more profile-manifest fields shape this: `dsh.profile.firstParty` lists installed packages treated as built in (a first-party package linked in during development), and `dependencies` versus `dsh.profile.bundles` distinguishes a package that is merely installed from one whose layer is enabled. Row ids share one namespace across the stack: built-in layers own theirs first, an external bundle that declares an id another layer already owns, or declares one of its own ids twice, is left out whole and reported on stderr and in the plugin list, and a user-layer insert of a taken id is dropped and reported the same way.
+A bundle you installed with `dsh plugin` is an **external** bundle: its rows mount under one contained group named `bundle/<package>` with the ids its patch declares, and a row that fails to start is isolated and recorded instead of stopping the process — the group and its other rows stay up, and the plugin list shows the failure. Template bundles are built in and keep failing loud. A bundle that provides a service built-in rows inject must mount like a built-in one: its author declares `dsh.bundle.stage: boot` in `package.json`, or you set `dsh.profile.stages` in the profile manifest, which wins. Even without that, an isolated failure that leaves a built-in row waiting for a service still stops the boot and names the isolated bundle. Two more profile-manifest fields shape this: `dsh.profile.firstParty` lists installed packages treated as built in (a first-party package linked in during development), and `dependencies` versus `dsh.profile.bundles` distinguishes a package that is merely installed from one whose layer is enabled. Row ids share one namespace across the stack, counting both the rows a layer inserts and the rows its patches set as a group's config: built-in layers own theirs first, an external bundle that declares an id another layer already owns, or declares one of its own ids twice, is left out whole and reported on stderr and in the plugin list, and a user-layer insert of a taken id is dropped and reported the same way.
 
-After the tree is up the launcher provides `ctx.profileRuntime`, which holds the composition the tree runs — the profile, the layer that owns each row, and the rows the composition left out — reads which rows the user patch files disable, and is the one entry point that recomposes the tree: the patch watchers and a runtime bundle enable or install all call it, and a rejected update leaves its facts describing the tree still running. Startup's fail-loud rejection guard is uninstalled once the tree is up: an unhandled rejection after boot is reported and the process keeps running, with nothing stopped or attributed to a plugin; an uncaught exception is reported and exits.
+After the tree is up the launcher provides `ctx.profileRuntime`, which holds the composition the tree runs — the profile, the layer that owns each row, and the rows the composition left out — reads which rows the user patch files disable, and is the one entry point that recomposes the tree: the patch watchers and a runtime bundle enable or install all call it, recompositions run one at a time, and a rejected update leaves its facts describing the tree still running. Startup's fail-loud rejection guard is uninstalled once the tree is up: an unhandled rejection after boot is reported and the process keeps running, with nothing stopped or attributed to a plugin; an uncaught exception is reported and exits.
 
 ### Previewing the effective configuration
 
@@ -90,7 +90,7 @@ This section explains how the outcomes above are realized and points at the code
 
 - **Channel-neutral library.** The package carries no loader hooks and no dev-mode surface; the [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence, and built consumers use plain Node package resolution.
 - **Three Loader builtins.** `mountRootInclude` registers `cordis:include`, `cordis:group`, and `cordis:contained-group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name, and the contained group is where external bundles mount. All load through the ambient module pipeline rather than the included tree's own specifier resolution.
-- **External bundles are groups.** The vendored `EntryGroup.update` is all-or-nothing, so `composeExternalLayer` wraps each `runtime`-stage external layer's inserts in one `cordis:contained-group` under the ids the bundle declares; the group's `create()` records a failed row on the root's `pluginFailures` registry instead of rejecting, a group that unmounts drops its rows' records, and `assertEntriesActivated` exempts recorded rows while still failing a built-in row left pending.
+- **External bundles are groups.** The vendored `EntryGroup.update` is all-or-nothing, so `composeExternalLayer` wraps each `runtime`-stage external layer's inserts in one `cordis:contained-group` under the ids the bundle declares; the group's `create()` records a failed row on the root's `pluginFailures` registry instead of rejecting, a group that updates drops the records of rows it no longer configures and one that unmounts drops them all, and `assertEntriesActivated` exempts recorded rows while still failing a built-in row left pending.
 - **Row ids are owned, not rewritten.** Entry ids are unique per tree and a `create()` that finds an existing id re-parents that entry instead of rejecting, so `composeProfileStack` decides ownership before anything mounts: built-in and boot-staged layers claim first and a duplicate among them fails the boot, a contained bundle that collides is left out whole, a user insert of a taken id is dropped, and every such row is a `conflict` record in `pluginFailures`. Boot, live recomposition, and `--dump-config` compose through the same function.
 - **Fail-loud is boot-scoped.** `installFailLoud` exits on any unhandled rejection because during startup one is a load failure; the launcher uninstalls it once the tree is up and installs `installRuntimeGuards`, which reports a rejection and keeps running and exits on an uncaught exception. Nested fibers (a `ctx.inject()` continuation) that fail under a built-in entry are reported by `warnNestedFiberFailures` as advisory lines.
 - **The probe never runs a package in the host.** `probePackage` reads an installed package's manifest here and imports it in a child process that reports over an IPC channel, so a package that throws, exits, hangs, prints at import, or brings its own copy of cordis costs one child and yields a record with the reason; the child's report and the cached record are validated field by field before either is trusted. It calls a package a `plugin` only when the package declares itself to dsh — a `dsh` section or a dependency on `@deepseek-ai/cordis` — and its main export is plugin-shaped; a bare function export (`lodash`) is a `library`. Records are cached under the profile's `.dsh-plugins/` with a format number, so a record an older probe wrote is probed again rather than trusted.

+ 3 - 3
packages/boot/app-boot/README.zh.md

@@ -58,9 +58,9 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目及其嵌套分组中的绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。
 
-用 `dsh plugin` 安装的组合包是**外部**组合包:它的行挂在一个名为 `bundle/<package>` 的受控组下,id 保持它的 patch 所声明的样子,启动失败的行被隔离并记录而不是让进程停下——组和它的其他行继续运行,插件列表显示失败。模板组合包是内置的,仍然明确失败。若某个组合包提供内置行注入的服务,它必须像内置行一样挂载:作者在 `package.json` 里声明 `dsh.bundle.stage: boot`,或者你在 profile manifest 里设置 `dsh.profile.stages`,后者优先。即使没有这些声明,隔离的失败若让某个内置行停在等待服务的状态,启动仍会失败并点名那个被隔离的组合包。profile manifest 还有两个相关字段:`dsh.profile.firstParty` 列出按内置处理的已安装包(开发期 link 进来的一方包),`dependencies` 与 `dsh.profile.bundles` 的区别则把"只是装了"的包和"层已启用"的包分开。行 id 在整叠层里共用一个命名空间:内置层先占有自己的 id,外部组合包若声明了别的层已占有的 id,或把自己的某个 id 声明了两次,就整层被排除,并在 stderr 与插件列表里报告;用户层插入已被占用的 id 时该行被丢弃,同样报告。
+用 `dsh plugin` 安装的组合包是**外部**组合包:它的行挂在一个名为 `bundle/<package>` 的受控组下,id 保持它的 patch 所声明的样子,启动失败的行被隔离并记录而不是让进程停下——组和它的其他行继续运行,插件列表显示失败。模板组合包是内置的,仍然明确失败。若某个组合包提供内置行注入的服务,它必须像内置行一样挂载:作者在 `package.json` 里声明 `dsh.bundle.stage: boot`,或者你在 profile manifest 里设置 `dsh.profile.stages`,后者优先。即使没有这些声明,隔离的失败若让某个内置行停在等待服务的状态,启动仍会失败并点名那个被隔离的组合包。profile manifest 还有两个相关字段:`dsh.profile.firstParty` 列出按内置处理的已安装包(开发期 link 进来的一方包),`dependencies` 与 `dsh.profile.bundles` 的区别则把"只是装了"的包和"层已启用"的包分开。行 id 在整叠层里共用一个命名空间,既算一层插入的行也算它的 patch 设为某个组 config 的行:内置层先占有自己的 id,外部组合包若声明了别的层已占有的 id,或把自己的某个 id 声明了两次,就整层被排除,并在 stderr 与插件列表里报告;用户层插入已被占用的 id 时该行被丢弃,同样报告。
 
-树起来之后 launcher 提供 `ctx.profileRuntime`:它持有树正在运行的组合——profile、每一行的归属层、被组合排除的行——读取用户 patch 文件停用了哪些行,并且是重组整棵树的唯一入口:patch 监视器、运行时启用或安装组合包都调用它,被拒的更新留下的事实仍然描述正在运行的树。启动期的 fail-loud rejection 守卫在树起来后卸载:启动后未处理的 rejection 会被报告,进程继续运行,不停止任何任务也不归属到任何插件;未捕获的异常会被报告并退出。
+树起来之后 launcher 提供 `ctx.profileRuntime`:它持有树正在运行的组合——profile、每一行的归属层、被组合排除的行——读取用户 patch 文件停用了哪些行,并且是重组整棵树的唯一入口:patch 监视器、运行时启用或安装组合包都调用它,重组一次只跑一个,被拒的更新留下的事实仍然描述正在运行的树。启动期的 fail-loud rejection 守卫在树起来后卸载:启动后未处理的 rejection 会被报告,进程继续运行,不停止任何任务也不归属到任何插件;未捕获的异常会被报告并退出。
 
 ### 预览生效配置
 
@@ -90,7 +90,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 - **与渠道无关的库。** 此包不包含 loader 钩子,也不提供开发模式接口;[`dsh` 应用](../../../apps/cli/README.zh.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper,构建后的消费方则使用普通 Node 包解析。
 - **三个 Loader builtin。** `mountRootInclude` 把 `cordis:include`、`cordis:group` 与 `cordis:contained-group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`,受控组则是外部组合包挂载的位置。三者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。
-- **外部组合包即组。** vendored 的 `EntryGroup.update` 是整组事务,因此 `composeExternalLayer` 把每个 `runtime` 阶段外部层的插入行按组合包声明的 id 包进一个 `cordis:contained-group`;该组的 `create()` 把失败的行记录到根上的 `pluginFailures` 注册表而不是 reject,组卸载时丢掉自己各行的记录,`assertEntriesActivated` 豁免已记录的行,但内置行停在 pending 时仍然失败。
+- **外部组合包即组。** vendored 的 `EntryGroup.update` 是整组事务,因此 `composeExternalLayer` 把每个 `runtime` 阶段外部层的插入行按组合包声明的 id 包进一个 `cordis:contained-group`;该组的 `create()` 把失败的行记录到根上的 `pluginFailures` 注册表而不是 reject,组更新时丢掉配置里不再有的行的记录,卸载时全部丢掉,`assertEntriesActivated` 豁免已记录的行,但内置行停在 pending 时仍然失败。
 - **行 id 归属而非改写。** entry id 在整棵树内唯一,而 `create()` 遇到已有 id 时会把那个 entry 挪到自己名下而不是 reject,所以 `composeProfileStack` 在任何行挂载之前先判定归属:内置层与 boot 阶段的层先占有 id,它们之间重复即启动失败;撞名的受控组合包整层排除;用户层插入已被占用的 id 时该行丢弃;每一条被排除的行都是 `pluginFailures` 里的一条 `conflict` 记录。启动、运行时重组与 `--dump-config` 走同一个函数。
 - **fail-loud 只在启动期。** `installFailLoud` 对任何未处理 rejection 退出,因为启动期间它就是加载失败;树起来后 launcher 卸载它并安装 `installRuntimeGuards`:rejection 被报告并继续运行,未捕获异常被报告并退出。内置条目下失败的嵌套 fiber(`ctx.inject()` 的延续)由 `warnNestedFiberFailures` 以提示行报告。
 - **探针从不在宿主内运行包。** `probePackage` 在本进程读取已安装包的 manifest,在子进程里 import 它并经 IPC 通道接收报告,因此抛错、退出、挂起、import 时打印或自带 cordis 副本的包只消耗一个子进程,得到一条带原因的记录;子进程的报告与缓存记录都逐字段校验之后才被信任。只有包向 dsh 声明了自己——有 `dsh` 段或依赖 `@deepseek-ai/cordis`——且主导出是插件形状时才判为 `plugin`;光是导出一个函数(`lodash`)的包是 `library`。记录缓存在 profile 的 `.dsh-plugins/` 下并带格式号,旧版探针写的记录会重新探测而不是被信任。

+ 14 - 9
packages/boot/app-boot/src/compose-stack.ts

@@ -16,7 +16,7 @@
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import { composeExternalLayer, isContainedLayer, type ComposedExternalLayer } from './external-bundles.ts'
-import { visitInsertedRows, visitRowTree } from './patch-rows.ts'
+import { visitPatchRows, visitRowTree } from './patch-rows.ts'
 import type { ProfileLayer } from './profile.ts'
 
 /** One user-owned patch list in the stack: the profile file, the home file, or a `--patch` overlay. */
@@ -89,12 +89,14 @@ function rowIds(row: EntryOptions): string[] {
 }
 
 /**
- * Decide row-id ownership across the bundle layers. Built-in and boot-staged
- * layers claim first, in manifest order; an id two of them declare, or one
- * declares twice, is a defect of the shipped composition and throws.
- * Contained external layers then claim in manifest order, each rendered once
- * here; one whose id is already owned, or which declares an id twice, is left
- * out whole.
+ * Decide row-id ownership across the bundle layers. A layer introduces the
+ * rows it inserts and the rows its config overrides set as a group's
+ * children. Built-in and boot-staged layers claim first, in manifest order;
+ * an id two of them declare, or one inserts twice, is a defect of the shipped
+ * composition and throws, while a config override restating a row the same
+ * layer declared is that layer keeping its own child. Contained external
+ * layers then claim in manifest order, each rendered once here; one whose id
+ * is already owned, or which declares an id twice, is left out whole.
  * @param layers - the profile's bundle layers, in manifest order.
  * @returns the owner of every claimed id, the conflicts of each skipped bundle, and the composition of each mounted one.
  * @throws when two built-in or boot-staged layers declare the same id, or one of them declares an id twice.
@@ -103,10 +105,13 @@ export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
   const owners = new Map<string, ProfileLayer>()
   for (const layer of layers) {
     if (isContainedLayer(layer)) continue
-    visitInsertedRows(layer.patches, (row) => {
+    visitPatchRows(layer.patches, (row, source) => {
       if (typeof row.id !== 'string') return
       const owner = owners.get(row.id)
-      if (owner === layer) throw new Error(`row ${JSON.stringify(row.id)} is declared twice by ${layer.packageName}`)
+      if (owner === layer) {
+        if (source === 'insert') throw new Error(`row ${JSON.stringify(row.id)} is declared twice by ${layer.packageName}`)
+        return
+      }
       if (owner !== undefined) {
         throw new Error(`row ${JSON.stringify(row.id)} is declared by both ${owner.packageName} and ${layer.packageName}`)
       }

+ 43 - 8
packages/boot/app-boot/src/contained-group.ts

@@ -10,6 +10,7 @@
 
 import type { Context } from '@deepseek-ai/cordis'
 import { EntryUpdateError, Group, type Entry, type EntryGroup, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import { visitRowTree } from './patch-rows.ts'
 
 /** The lifecycle step at which a contained row failed. */
 export type ContainedFailureStage = 'import' | 'apply' | 'inject-pending' | 'unknown'
@@ -33,7 +34,10 @@ export interface ContainedFailure {
 /**
  * Failures recorded by contained groups of one runtime. Rows are keyed by
  * their tree-wide id; recording a row again replaces its earlier record, a
- * row that later mounts clears it, and a group that unmounts clears its rows'.
+ * row that later mounts clears it, a group that updates drops the records of
+ * rows it no longer configures, and a group that unmounts clears its rows'.
+ * A record's `groupId` names the contained group that isolates the row, at
+ * any nesting depth, so those two cleanups reach every row of a bundle.
  * Rows the composition left out never reach a group and are not recorded
  * here; `ProfileRuntime.conflicts` holds them.
  */
@@ -66,6 +70,18 @@ export class ContainedFailureRegistry {
     }
   }
 
+  /**
+   * Forget the records of one contained group's rows that its configuration
+   * no longer names, after the group updated.
+   * @param groupId - the group's tree-wide id.
+   * @param rowIds - the row ids the group still configures, at any depth.
+   */
+  retain(groupId: string, rowIds: ReadonlySet<string>): void {
+    for (const [entryId, failure] of this.failures) {
+      if (failure.groupId === groupId && !rowIds.has(failure.rowId)) this.failures.delete(entryId)
+    }
+  }
+
   /**
    * Every recorded failure in record order.
    * @returns the failures.
@@ -109,10 +125,19 @@ function stageOf(error: unknown): ContainedFailureStage {
  * per-row step `EntryGroup.update` awaits, so catching there is what turns a
  * row failure from a group rejection into a record: the group activates, the
  * failed row is absent from the tree, and the record names it. When the group
- * unmounts — its bundle disabled or uninstalled — its rows' records go with
- * it, so no failure outlives the composition that produced it.
+ * updates, the records of rows its configuration dropped go; when it unmounts
+ * — its bundle disabled or uninstalled — all its rows' records go with it, so
+ * no failure outlives the composition that produced it.
  */
 export class ContainedGroup extends Group {
+  override async update(config: EntryOptions[]): Promise<void> {
+    await super.update(config)
+    const configured = new Set<string>()
+    // `ensureId` named every row, nested ones included, while the update mounted it.
+    for (const row of config) visitRowTree(row, (entry) => { configured.add(entry.id) })
+    this.registry()?.retain(this.groupId(), configured)
+  }
+
   override async create(options: Omit<EntryOptions, 'id'>): Promise<string> {
     try {
       const id = await super.create(options)
@@ -160,17 +185,27 @@ export class ContainedGroup extends Group {
 }
 
 /**
- * Whether an entry sits inside a contained group, at any depth.
+ * The contained group that isolates an entry: the nearest owning group, at
+ * any depth, that is a {@link ContainedGroup}.
  * @param entry - the Loader entry to classify.
- * @returns true when some owning group is a {@link ContainedGroup}.
+ * @returns the group, or undefined for an entry outside every contained group.
  */
-export function isContainedEntry(entry: Entry): boolean {
+export function containingGroup(entry: Entry): ContainedGroup | undefined {
   let group: EntryGroup | undefined = entry.parent
   while (group !== undefined) {
-    if (group instanceof ContainedGroup) return true
+    if (group instanceof ContainedGroup) return group
     group = group.ctx.fiber.entry?.parent
   }
-  return false
+  return undefined
+}
+
+/**
+ * Whether an entry sits inside a contained group, at any depth.
+ * @param entry - the Loader entry to classify.
+ * @returns true when some owning group is a {@link ContainedGroup}.
+ */
+export function isContainedEntry(entry: Entry): boolean {
+  return containingGroup(entry) !== undefined
 }
 
 /**

+ 39 - 17
packages/boot/app-boot/src/external-bundles.ts

@@ -16,7 +16,7 @@
 
 import { join } from 'node:path'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import { visitInsertedRows } from './patch-rows.ts'
+import { visitPatchRows } from './patch-rows.ts'
 import {
   readProfileManifest, resolveBundleDir, writeProfileManifest, type ProfileLayer, type ProfileManifest,
 } from './profile.ts'
@@ -38,7 +38,7 @@ export function bundleGroupId(packageName: string): string {
   return `${BUNDLE_GROUP_PREFIX}${packageName}`
 }
 
-/** One row id an external bundle's own patch inserts more than once. */
+/** One id an external bundle's patch introduces twice: a row inserted again, or a row spelling the group's or a wrapper's id. */
 export interface DuplicateRow {
   /** The repeated id. */
   readonly rowId: string
@@ -54,11 +54,12 @@ export interface ComposedExternalLayer {
    */
   patches: PatchOptions[]
   /**
-   * Every id the layer introduces — its rows, its group, and each wrapper
-   * group — with the module each names; a repeated id keeps its first module.
+   * Every id the layer introduces — its inserted rows, the rows its config
+   * overrides set, its group, and each wrapper group — with the module each
+   * names; a repeated id keeps its first module.
    */
   rows: Map<string, string>
-  /** Ids the bundle's own inserts declare more than once, in order of repetition. */
+  /** Ids the layer introduces more than once, in order of repetition; a config override restating a row is not one. */
   duplicates: DuplicateRow[]
   /** Ids outside the bundle that its patch overrides; not containable, reported for visibility. */
   overrides: string[]
@@ -77,13 +78,18 @@ export function isContainedLayer(layer: ProfileLayer): boolean {
 /**
  * Render one external bundle layer as contained patches in the order written.
  * The bundle's group is inserted empty first; each root insert becomes an
- * insert into that group, an insert into a row the bundle itself inserts
+ * insert into that group, an insert into a row the bundle itself introduces
  * passes through, and every insert into one built-in group lands in one
  * wrapper group nested inside that target — the first insert creates it,
- * later ones insert into it. An id-targeted patch passes through unchanged
- * and is reported as an override when it addresses a row the bundle did not
- * insert. Ids are indexed before any patch is emitted, so an insert into a
- * group the bundle inserts later in its list still counts as its own.
+ * later ones insert into it, and a patch that replaces the target's config
+ * has removed the wrapper, so the next insert creates a new one under the
+ * same id. An id-targeted patch passes through unchanged and is reported as
+ * an override when it addresses a row the bundle did not introduce; the rows
+ * it sets as a group's config count as the bundle's own, since they mount
+ * as children like inserted ones. Every id, declared or generated, goes
+ * through one registration, so a row spelling a wrapper's id is a duplicate.
+ * Ids are indexed before any patch is emitted, so an insert into a group the
+ * bundle introduces later in its list still counts as its own.
  * @param layer - the resolved external layer.
  * @returns the patches to mount, the ids the layer introduces, and the ids it repeats.
  */
@@ -91,19 +97,32 @@ export function composeExternalLayer(layer: ProfileLayer): ComposedExternalLayer
   const groupId = bundleGroupId(layer.packageName)
   const rows = new Map<string, string>()
   const duplicates: DuplicateRow[] = []
-  visitInsertedRows(layer.patches, (row) => {
+  const claim = (rowId: string, moduleName: string): void => {
+    if (rows.has(rowId)) duplicates.push({ rowId, moduleName })
+    else rows.set(rowId, moduleName)
+  }
+  visitPatchRows(layer.patches, (row, source) => {
     if (typeof row.id !== 'string') return
-    if (rows.has(row.id)) duplicates.push({ rowId: row.id, moduleName: row.name })
-    else rows.set(row.id, row.name)
+    // A config override restates the children it keeps: the id counts as
+    // the bundle's own without being a repeat.
+    if (source === 'config') {
+      if (!rows.has(row.id)) rows.set(row.id, row.name)
+      return
+    }
+    claim(row.id, row.name)
   })
-  if (rows.has(groupId)) duplicates.push({ rowId: groupId, moduleName: CONTAINED_GROUP_MODULE })
-  rows.set(groupId, CONTAINED_GROUP_MODULE)
+  claim(groupId, CONTAINED_GROUP_MODULE)
   const wrappers = new Map<string, string>()
+  const generated = new Set<string>()
   const overrides: string[] = []
   const patches: PatchOptions[] = [{ insert: [{ id: groupId, name: CONTAINED_GROUP_MODULE, group: true, config: [] }] }]
   for (const patch of layer.patches) {
     if (patch.insert === undefined) {
-      if (patch.id !== undefined && !rows.has(patch.id)) overrides.push(patch.id)
+      if (patch.id !== undefined) {
+        if (!rows.has(patch.id)) overrides.push(patch.id)
+        // The target's children are replaced, wrapper included.
+        if (patch.config !== undefined) wrappers.delete(patch.id)
+      }
       patches.push(structuredClone(patch))
       continue
     }
@@ -119,8 +138,11 @@ export function composeExternalLayer(layer: ProfileLayer): ComposedExternalLayer
       continue
     }
     const wrapperId = `${groupId}/in/${target}`
+    if (!generated.has(wrapperId)) {
+      generated.add(wrapperId)
+      claim(wrapperId, CONTAINED_GROUP_MODULE)
+    }
     wrappers.set(target, wrapperId)
-    rows.set(wrapperId, CONTAINED_GROUP_MODULE)
     patches.push({ id: target, insert: [{ id: wrapperId, name: CONTAINED_GROUP_MODULE, group: true, config: inserted }] })
   }
   return { patches, rows, duplicates, overrides }

+ 8 - 8
packages/boot/app-boot/src/index.ts

@@ -19,8 +19,8 @@ import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
 import type {} from '@deepseek-ai/cordis-plugin-hmr'
 import type {} from '@deepseek-ai/dsh-system-prompt'
-import { ContainedGroup, ensurePluginFailures, isContainedEntry } from './contained-group.ts'
-import { visitInsertedRows } from './patch-rows.ts'
+import { ContainedGroup, containingGroup, ensurePluginFailures, isContainedEntry } from './contained-group.ts'
+import { visitPatchRows } from './patch-rows.ts'
 
 declare module '@deepseek-ai/cordis' {
   interface Context {
@@ -51,7 +51,7 @@ export {
   type ProfileTemplate,
 } from './profile.ts'
 export {
-  ContainedFailureRegistry, ContainedGroup, ensurePluginFailures, isContainedEntry,
+  ContainedFailureRegistry, ContainedGroup, containingGroup, ensurePluginFailures, isContainedEntry,
   type ContainedFailure, type ContainedFailureStage,
 } from './contained-group.ts'
 export {
@@ -343,10 +343,10 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[
   return parsePatchList(binName, file, content, 'overlay')
 }
 
-/** Convert inserted filesystem paths to file URLs, anchoring relative paths beside the patch; keep assertion names literal. */
+/** Convert introduced rows' filesystem paths to file URLs, anchoring relative paths beside the patch; keep assertion names literal. */
 function anchorInsertedPluginNames(patches: PatchOptions[], file: string): PatchOptions[] {
   const base = dirname(resolve(file))
-  visitInsertedRows(patches, (entry) => {
+  visitPatchRows(patches, (entry) => {
     if (typeof entry.name === 'string' && (isAbsolute(entry.name) || entry.name.startsWith('./') || entry.name.startsWith('../'))) {
       entry.name = pathToFileURL(resolve(base, entry.name)).href
     }
@@ -782,10 +782,10 @@ function formatActivationError(error: unknown): string {
   return error instanceof Error ? error.stack ?? error.message : String(error)
 }
 
-/** The tree-wide id of the group entry that owns a contained row. */
+/** The tree-wide id of the contained group that isolates a row, whatever plain groups sit between. */
 function owningGroupId(entry: Entry): string {
-  /* v8 ignore next -- a contained entry is by definition inside a group entry; the fallback keeps the type total */
-  return entry.parent.ctx.fiber.entry?.id ?? ''
+  /* v8 ignore next -- only contained entries are recorded and their group runs as a Loader entry; the fallback keeps the type total */
+  return containingGroup(entry)?.ctx.fiber.entry?.id ?? ''
 }
 
 /**

+ 28 - 11
packages/boot/app-boot/src/patch-rows.ts

@@ -1,17 +1,25 @@
 /**
- * The one walk over the rows a patch list inserts. Composition, ownership,
- * the package probe, and patch loading all read inserted rows and their
- * group children; sharing the walk keeps them reading the same tree.
+ * The one walk over the rows a patch list introduces. Composition, ownership,
+ * the package probe, and patch loading all read those rows and their group
+ * children; sharing the walk keeps them reading the same tree.
  * @module @deepseek-ai/dsh-app-boot/patch-rows
  */
 
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 
+/** How a patch introduced a row: inserted it, or set it as a group's `config`. */
+export type PatchRowSource = 'insert' | 'config'
+
+/** Whether a config item is a row: a plain object naming a module. */
+function isRowLike(value: unknown): value is EntryOptions {
+  return typeof value === 'object' && value !== null && !Array.isArray(value) && typeof (value as { name?: unknown }).name === 'string'
+}
+
 /**
- * Visit one inserted row and, for a group with a list config, each of its
- * children, parents before children, in written order.
- * @param row - the inserted row.
+ * Visit one row and, for a group with a list config, each of its children,
+ * parents before children, in written order.
+ * @param row - the row.
  * @param visit - called once per row.
  */
 export function visitRowTree(row: EntryOptions, visit: (row: EntryOptions) => void): void {
@@ -22,13 +30,22 @@ export function visitRowTree(row: EntryOptions, visit: (row: EntryOptions) => vo
 }
 
 /**
- * Visit every row a patch list inserts, at any depth, in written order.
- * Id-targeted patches insert nothing and are skipped.
+ * Visit every row a patch list introduces, at any depth, in written order:
+ * the rows a patch inserts, and the rows an id-targeted patch sets as a
+ * group's `config`, which mount as that group's children the same way an
+ * insert's do.
  * @param patches - the patch list.
- * @param visit - called once per inserted row.
+ * @param visit - called once per row, with how the patch introduced it.
  */
-export function visitInsertedRows(patches: readonly PatchOptions[], visit: (row: EntryOptions) => void): void {
+export function visitPatchRows(patches: readonly PatchOptions[], visit: (row: EntryOptions, source: PatchRowSource) => void): void {
   for (const patch of patches) {
-    for (const row of patch.insert ?? []) visitRowTree(row, visit)
+    if (patch.insert !== undefined) {
+      for (const row of patch.insert) visitRowTree(row, (entry) => { visit(entry, 'insert') })
+      continue
+    }
+    if (patch.id === undefined || !Array.isArray(patch.config)) continue
+    for (const item of patch.config as unknown[]) {
+      if (isRowLike(item)) visitRowTree(item, (entry) => { visit(entry, 'config') })
+    }
   }
 }

+ 4 - 4
packages/boot/app-boot/src/probe.ts

@@ -15,7 +15,7 @@ import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { loadOverlayPatches } from './index.ts'
 import { readProfileManifest, resolveBundleDir, type ProfileManifest } from './profile.ts'
-import { visitInsertedRows } from './patch-rows.ts'
+import { visitPatchRows } from './patch-rows.ts'
 import { isRecord, parseChildReport, type ChildReport } from './probe-report.ts'
 
 /** Directory under a profile holding one probe record per package. */
@@ -84,7 +84,7 @@ export interface PluginProbe {
   readonly cordisSameCopy: boolean | null
   /** The harness version range the package declares in `engines.dsh`. */
   readonly enginesDsh?: string
-  /** Rows the bundle's patch inserts (unprefixed), empty for a non-bundle. */
+  /** Rows the bundle's patch introduces, inserted or set as a group's config, empty for a non-bundle. */
   readonly rows: readonly PluginProbeRow[]
   /** Ids of rows outside the bundle that its patch overrides. */
   readonly overrides: readonly string[]
@@ -184,12 +184,12 @@ function runChild(options: ProbeOptions, packageDir: string, mainSpecifier: stri
 
 const DEFAULT_TIMEOUT_MS = 20_000
 
-/** The rows a bundle patch inserts, flattened from nested groups, with the ids of overrides on other rows. */
+/** The rows a bundle patch introduces, flattened from nested groups, with the ids of overrides on other rows. */
 function describeBundlePatch(binName: string, patchPath: string): { rows: PluginProbeRow[]; overrides: string[] } {
   const rows: PluginProbeRow[] = []
   const own = new Set<string>()
   const patches = loadOverlayPatches(binName, patchPath)
-  visitInsertedRows(patches, (row) => {
+  visitPatchRows(patches, (row) => {
     if (typeof row.id === 'string') own.add(row.id)
     rows.push({ ...typeof row.id === 'string' ? { id: row.id } : {}, name: row.name, gated: row.disabled !== undefined })
   })

+ 14 - 0
packages/boot/app-boot/src/profile-runtime.ts

@@ -6,6 +6,9 @@
  * include, and publishes the profile, the stack's id ownership, and its
  * conflicts only once the include accepted it; a rejected update leaves the
  * committed composition in place, which describes the tree still running.
+ * Recompositions run one at a time: each reads the committed composition
+ * only after the previous one settled, so a watcher firing while a bundle is
+ * being enabled recomposes the enabled tree instead of the one before it.
  * Before this service the composition closure lived in the launcher and
  * bundle layers were frozen at boot, so nothing in the tree could learn which
  * profile it ran in or add a layer while running.
@@ -62,6 +65,8 @@ interface CommittedComposition {
 /** Facts and recomposition of the booted profile. */
 export class ProfileRuntime extends Service {
   private committed: CommittedComposition
+  /** The recomposition in flight, or a settled promise; the next one chains behind it. */
+  private queue: Promise<void> = Promise.resolve()
 
   constructor(ctx: Context, private readonly options: ProfileRuntimeOptions) {
     super(ctx, 'profileRuntime')
@@ -143,11 +148,20 @@ export class ProfileRuntime extends Service {
    * its ownership, and its conflicts become the committed composition only
    * once the update holds; until then, and after a rejection, `current`,
    * `layers`, `originOf`, and `conflicts` keep describing the running tree.
+   * Calls queue: one that arrives while another is in flight starts after it
+   * settled and reads what it committed. A rejection is that call's outcome
+   * alone and does not stop the ones behind it.
    * @param options - `reloadBundles` re-reads the profile manifest first, so a
    * bundle enabled or installed since boot joins the stack.
    * @throws when the root include is not mounted, or the Loader rejected the update.
    */
   async recompose(options: { reloadBundles?: boolean } = {}): Promise<void> {
+    const run = this.queue.then(() => this.recomposeNow(options))
+    this.queue = run.then(() => undefined, () => undefined)
+    return run
+  }
+
+  private async recomposeNow(options: { reloadBundles?: boolean }): Promise<void> {
     const entry = this.options.rootEntry()
     if (entry === undefined) throw new Error('profileRuntime: the root include is not mounted')
     const profile = options.reloadBundles === true ? this.options.loadProfile() : this.committed.profile

+ 30 - 0
packages/boot/app-boot/tests/compose-stack.spec.ts

@@ -45,6 +45,36 @@ describe('claimLayerIds', () => {
     expect(() => claimLayerIds([stutter])).toThrow(/row "x" is declared twice by stutter/)
   })
 
+  it('leaves out a bundle whose config override sets a row another layer owns', () => {
+    const hijack = layer('hijack', 'external', [
+      { insert: [{ id: 'own', name: 'cordis:group', group: true, config: [] }] },
+      { id: 'own', config: [{ id: 'settings', name: 'hijack/impostor' }] },
+    ])
+    const { skipped } = claimLayerIds([base, hijack])
+    expect(skipped.get('hijack')).toEqual([
+      {
+        rowId: 'settings', moduleName: 'hijack/impostor', layer: 'hijack', packageName: 'hijack', declaredBy: '@deepseek-ai/dsh-base',
+        message: 'row "settings" is already declared by @deepseek-ai/dsh-base',
+      },
+    ])
+    const stack = composeProfileStack(NAME, [base, hijack], [])
+    expect(stack.skippedBundles).toEqual(['hijack'])
+    expect(stack.owners.get('settings')?.packageName).toBe('@deepseek-ai/dsh-base')
+  })
+
+  it('lets a built-in layer restate its own rows through a config override, and rejects another layer\'s', () => {
+    const restating = layer('restating', 'builtin', [
+      { insert: [{ id: 'g', name: 'cordis:group', group: true, config: [{ id: 'a', name: 'a' }] }] },
+      { id: 'g', config: [{ id: 'a', name: 'a' }, { id: 'b', name: 'b' }] },
+    ])
+    expect([...claimLayerIds([restating]).owners.keys()]).toEqual(['g', 'a', 'b'])
+    const taking = layer('taking', 'builtin', [
+      { insert: [{ id: 'h', name: 'cordis:group', group: true, config: [] }] },
+      { id: 'h', config: [{ id: 'settings', name: 'taking/impostor' }] },
+    ])
+    expect(() => claimLayerIds([base, taking])).toThrow(/row "settings" is declared by both @deepseek-ai\/dsh-base and taking/)
+  })
+
   it('leaves out a bundle that declares one of its own ids twice and composes each mounted bundle once', () => {
     const stutter = layer('stutter', 'external', [{ insert: [{ id: 'x', name: 'stutter/a' }, { id: 'x', name: 'stutter/b' }] }])
     const clean = layer('clean', 'external', [{ insert: [{ id: 'y', name: 'clean' }] }])

+ 43 - 0
packages/boot/app-boot/tests/contained-group.spec.ts

@@ -194,6 +194,49 @@ describe('cordis:contained-group', () => {
     expect(registry.list().map(failure => failure.entryId)).toEqual(['include:other/bad'])
   })
 
+  it('drops the record of a row its configuration no longer names, while the group stays', async () => {
+    const ctx = await boot(NAME, stage(`
+- id: bundle/ext
+  name: cordis:contained-group
+  group: true
+  config:
+    - id: ext/bad
+      name: cordis:throws
+    - id: ext/ok
+      name: cordis:good
+`), [], prepare)
+    contexts.push(ctx)
+    const registry = ctx.get('pluginFailures') as ContainedFailureRegistry
+    expect(registry.get('include:ext/bad')).toBeDefined()
+    const group = ctx.loader.resolve('include:bundle/ext')
+    // The bundle updates to a version without the failing row: the group is updated, not unmounted.
+    await group.update({ config: [{ id: 'ext/ok', name: 'cordis:good' }] })
+    expect(registry.get('include:ext/bad')).toBeUndefined()
+    expect([...ctx.loader.entries()].some(entry => entry.id === 'include:ext/ok')).toBe(true)
+  })
+
+  it('records a pending row of a nested plain group under the contained group that isolates it, so unmounting the bundle clears it', async () => {
+    const ctx = await boot(NAME, stage(`
+- id: bundle/ext
+  name: cordis:contained-group
+  group: true
+  config:
+    - id: ext/inner
+      name: cordis:group
+      group: true
+      config:
+        - id: ext/waiting
+          name: cordis:pending
+        - name: cordis:good
+`), [], prepare)
+    contexts.push(ctx)
+    const registry = ctx.get('pluginFailures') as ContainedFailureRegistry
+    expect(registry.get('include:ext/waiting')).toEqual(expect.objectContaining({ stage: 'inject-pending', groupId: 'include:bundle/ext' }))
+    const group = ctx.loader.resolve('include:bundle/ext')
+    await group.parent.remove(group.options.id)
+    expect(registry.get('include:ext/waiting')).toBeUndefined()
+  })
+
   it('provides one registry per runtime', async () => {
     const ctx = new Context()
     contexts.push(ctx)

+ 47 - 0
packages/boot/app-boot/tests/external-bundles.spec.ts

@@ -60,12 +60,17 @@ describe('composeExternalLayer', () => {
       { id: 'own', config: { flag: true } },
       { id: 'settings', config: { path: '/x' } },
       { id: 'own', insert: [{ id: 'child', name: 'pkg-b/child' }] },
+      { id: 'own', disabled: true },
+      { disabled: true },
     ]))
+    // A patch with no config, or no id at all, passes through as written; the include reports the latter.
     expect(composed.patches.slice(1)).toEqual([
       { id: 'bundle/pkg-b', insert: [{ id: 'own', name: 'pkg-b' }] },
       { id: 'own', config: { flag: true } },
       { id: 'settings', config: { path: '/x' } },
       { id: 'own', insert: [{ id: 'child', name: 'pkg-b/child' }] },
+      { id: 'own', disabled: true },
+      { disabled: true },
     ])
     expect(composed.overrides).toEqual(['settings'])
   })
@@ -108,6 +113,43 @@ describe('composeExternalLayer', () => {
     expect((shell[0]?.config as EntryOptions[]).map(row => row.id)).toEqual(['extra', 'more'])
   })
 
+  it('counts the rows a config override sets as the bundle\'s own, without calling a restated row a repeat', () => {
+    const composed = composeExternalLayer(layer('pkg-v', [
+      { insert: [{ id: 'own', name: 'cordis:group', group: true, config: [{ id: 'kept', name: 'pkg-v/kept' }] }] },
+      { id: 'own', config: [{ id: 'kept', name: 'pkg-v/kept' }, { id: 'core', name: 'pkg-v/impostor' }, 'not a row' as never] },
+      { id: 'own', insert: [{ id: 'child', name: 'pkg-v/child' }] },
+    ]))
+    expect([...composed.rows.keys()]).toEqual(['own', 'kept', 'core', 'child', 'bundle/pkg-v'])
+    expect(composed.duplicates).toEqual([])
+    expect(composed.overrides).toEqual([])
+  })
+
+  it('creates a new wrapper after a patch replaced the target group\'s config', () => {
+    const composed = composeExternalLayer(layer('pkg-w', [
+      { id: 'tools', insert: [{ id: 'first', name: 'pkg-w/a' }] },
+      { id: 'tools', config: [] },
+      { id: 'tools', insert: [{ id: 'last', name: 'pkg-w/b' }] },
+    ]))
+    expect(composed.patches.slice(1)).toEqual([
+      { id: 'tools', insert: [{ id: 'bundle/pkg-w/in/tools', name: CONTAINED_GROUP_MODULE, group: true, config: [{ id: 'first', name: 'pkg-w/a' }] }] },
+      { id: 'tools', config: [] },
+      { id: 'tools', insert: [{ id: 'bundle/pkg-w/in/tools', name: CONTAINED_GROUP_MODULE, group: true, config: [{ id: 'last', name: 'pkg-w/b' }] }] },
+    ])
+    expect(composed.duplicates).toEqual([])
+    const tree = applyEntryPatches([{ id: 'tools', name: 'cordis:group', group: true, config: [] }], composed.patches, () => {})
+    const tools = tree.find(row => row.id === 'tools')?.config as EntryOptions[]
+    expect(tools.map(row => row.id)).toEqual(['bundle/pkg-w/in/tools'])
+    expect((tools[0]?.config as EntryOptions[]).map(row => row.id)).toEqual(['last'])
+  })
+
+  it('reports a declared row that spells a wrapper\'s id as a repeat', () => {
+    const composed = composeExternalLayer(layer('pkg-i', [
+      { insert: [{ id: 'bundle/pkg-i/in/tools', name: 'pkg-i/impostor' }] },
+      { id: 'tools', insert: [{ id: 'tool', name: 'pkg-i/tool' }] },
+    ]))
+    expect(composed.duplicates).toEqual([{ rowId: 'bundle/pkg-i/in/tools', moduleName: CONTAINED_GROUP_MODULE }])
+  })
+
   it('reports an id the bundle inserts twice and keeps the first module for it', () => {
     const composed = composeExternalLayer(layer('pkg-r', [
       { insert: [{ id: 'dup', name: 'pkg-r/one' }] },
@@ -132,14 +174,19 @@ describe('composeExternalLayer', () => {
     const composed = composeExternalLayer(layer('pkg-t', [
       { insert: [{ id: 'row', name: 'pkg-t' }] },
       { id: 'tools', insert: [{ id: 'tool', name: 'pkg-t/tool' }] },
+      { id: 'tools', config: [] },
+      { id: 'tools', insert: [{ id: 'later', name: 'pkg-t/later' }] },
     ]))
     const snapshot = structuredClone(composed.patches)
     const base = (): EntryOptions[] => [{ id: 'tools', name: 'cordis:group', group: true, config: [] }]
     const first = applyEntryPatches(base(), composed.patches, () => {})
     const second = applyEntryPatches(base(), composed.patches, () => {})
     expect(second).toEqual(first)
+    // Neither the inserted rows nor the override values were mutated by either application.
     expect(composed.patches).toEqual(snapshot)
     expect((first[1]?.config as EntryOptions[]).map(row => row.id)).toEqual(['row'])
+    const tools = first[0]?.config as EntryOptions[]
+    expect((tools[0]?.config as EntryOptions[]).map(row => row.id)).toEqual(['later'])
   })
 
   it('spells the group id without the Loader\'s nested-id separator', () => {

+ 38 - 0
packages/boot/app-boot/tests/profile-runtime.spec.ts

@@ -123,6 +123,44 @@ describe('ProfileRuntime', () => {
     expect(runtime.conflicts).toEqual(conflicts)
   })
 
+  it('runs recompositions one at a time, each from what the previous one committed', async () => {
+    let release = (): void => {}
+    const gate = new Promise<void>((resolve) => { release = resolve })
+    const applied: string[] = []
+    const update = vi.fn(async (options: { config: { patches: PatchOptions[] } }) => {
+      applied.push(options.config.patches[0]?.id ?? '')
+      if (applied.length === 1) await gate
+    })
+    const entry = { options: { config: { path: 'file:///root/cordis.yml' } }, update } as unknown as Entry
+    const reloaded = profile([layer('a', 'builtin', []), layer('b', 'external', [])])
+    const { runtime, compose } = await harness([layer('a', 'builtin', [])], { rootEntry: () => entry, reloaded })
+    // Enabling a bundle re-reads the profile and waits on the tree; a watcher fires meanwhile.
+    const enabling = runtime.recompose({ reloadBundles: true })
+    const watching = runtime.recompose()
+    await Promise.resolve()
+    expect(applied).toEqual(['composed-for-2'])
+    release()
+    await Promise.all([enabling, watching])
+    // The watcher composed from the profile the enable committed, not the one before it.
+    expect(applied).toEqual(['composed-for-2', 'composed-for-2'])
+    expect(compose.mock.calls.at(-1)?.[0]).toBe(reloaded)
+    expect(runtime.layers.map(current => current.packageName)).toEqual(['a', 'b'])
+  })
+
+  it('lets a rejected recomposition fail its own caller without blocking the next', async () => {
+    let calls = 0
+    const update = vi.fn(async () => {
+      calls += 1
+      if (calls === 1) throw new Error('rejected')
+    })
+    const entry = { options: { config: { path: 'file:///root/cordis.yml' } }, update } as unknown as Entry
+    const { runtime } = await harness([layer('a', 'builtin', [])], { rootEntry: () => entry })
+    const first = runtime.recompose()
+    const second = runtime.recompose()
+    await expect(first).rejects.toThrow('rejected')
+    await expect(second).resolves.toBeUndefined()
+  })
+
   it('keeps the committed profile, provenance, and conflicts when the root include rejects the update', async () => {
     const entry = { options: { config: { path: 'file:///root/cordis.yml' } }, update: vi.fn(async () => { throw new Error('rejected') }) } as unknown as Entry
     const reloaded = profile([layer('a', 'builtin', []), layer('b', 'external', [])])

+ 1 - 1
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -1405,7 +1405,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       {
         signature: 'async recompose(options: { reloadBundles?: boolean } = {}): Promise<void>',
-        description: 'Recompose the host tree from the profile\'s layers and the user patch files as they stand now. The root Include re-applies the stack transactionally: a row whose options changed is updated in place, a row that appeared is created, a row that vanished is disposed, and a failure rolls the whole update back with the previous tree still running. The candidate profile, its ownership, and its conflicts become the committed composition only once the update holds; until then, and after a rejection, `current`, `layers`, `originOf`, and `conflicts` keep describing the running tree.',
+        description: 'Recompose the host tree from the profile\'s layers and the user patch files as they stand now. The root Include re-applies the stack transactionally: a row whose options changed is updated in place, a row that appeared is created, a row that vanished is disposed, and a failure rolls the whole update back with the previous tree still running. The candidate profile, its ownership, and its conflicts become the committed composition only once the update holds; until then, and after a rejection, `current`, `layers`, `originOf`, and `conflicts` keep describing the running tree. Calls queue: one that arrives while another is in flight starts after it settled and reads what it committed. A rejection is that call\'s outcome alone and does not stop the ones behind it.',
         parameters: [{ name: 'options', description: '`reloadBundles` re-reads the profile manifest first, so a bundle enabled or installed since boot joins the stack.' }],
         throws: ['when the root include is not mounted, or the Loader rejected the update.'],
       },

+ 1 - 1
vendor/README.md

@@ -50,7 +50,7 @@ Keep this log exhaustive — every divergence from upstream must be listed.
 18. **Entry `disabled` interpolation in `loader/src/config/entry.ts`**: a `disabled: !!js` expression evaluates against the loader context at every mount decision; the raw node stays in the options, so write-back keeps the `!!js` form. `disabled` is the only interpolated metadata field. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts` and `apps/cli/tests/windows-shell.spec.ts`.
 19. **`loader/src/internal.ts` runtime shape detection**: `ModuleLoader.fromInternal()` classifies the internal loader by which module-job API it owns — `getOrCreateModuleJob` for v2, `getModuleJobForImport` for v1 — instead of by Node major version. Upstream tags every major `>= 24` as v2, but the v2 interface arrived in Node 24.12.0, so 24.0–24.11.1 report major 24 while still carrying the v1 loader; consumers then called `resolveSync` with reversed parameters and every call threw. `dsh web` served an empty client graph (`__DSH_BOOT__.entries: []`) and HMR partial reload resolved no entry URL, both behind swallowed or warn-level errors. Arity cannot discriminate the two shapes, because each reports `resolveSync.length === 2`. A loader owning neither API is left unclassified rather than guessed, so consumers take their documented no-internals path. Covered on the `node-compat` Node version matrix, which pins 24.9 for the mistagged range.
 20. **`loader/src/config/entry.ts` typed update errors**: the per-row `failed to <stage> loader entry` wrapper is an exported `EntryUpdateError` carrying its `stage`, so a consumer that classifies a row failure reads the field instead of parsing the message. The message text is unchanged. Covered by `packages/boot/app-boot/tests/contained-group.spec.ts`.
-21. **`include/src/index.ts` detached inserts**: `applyEntryPatches` clones inserted rows before adding them to the list, so a later patch that inserts into or overrides a row an earlier patch inserted mutates the copy and applying one patch list twice yields the same tree — the Loader re-applies the previous include config when it rolls a rejected update back, and an aliased insert row accumulated its children on every application. Covered by `packages/boot/app-boot/tests/external-bundles.spec.ts`.
+21. **`include/src/index.ts` detached inserts and overrides**: `applyEntryPatches` clones inserted rows and object-valued override values before they join the list, so a later patch that inserts into or overrides a row an earlier patch introduced mutates the copy and applying one patch list twice yields the same tree — the Loader re-applies the previous include config when it rolls a rejected update back, and an aliased insert row accumulated its children on every application while an aliased override value carried them back into the patch itself. Covered by `packages/boot/app-boot/tests/external-bundles.spec.ts`.
 
 ## Sync procedure
 

+ 7 - 6
vendor/include/src/index.ts

@@ -47,11 +47,12 @@ function retryableWriteError(error: unknown): boolean {
  * is never mutated and the result is always detached from it (even with no
  * patches): patching or mounting shared entry objects would bake earlier
  * values into the cached parse, so repeated application (config hot-reloads)
- * could never revert a removed or changed patch. Inserted rows are cloned
- * before they join the list for the same reason: a later patch that inserts
- * into or overrides a row an earlier patch inserted mutates the copy, so
- * applying one patch list twice (the Loader rolling a rejected update back to
- * the previous one) yields the same tree each time. Inserted entries are
+ * could never revert a removed or changed patch. Inserted rows and override
+ * values are cloned before they join the list for the same reason: a later
+ * patch that inserts into or overrides a row an earlier patch introduced
+ * mutates the copy, never the patch, so applying one patch list twice (the
+ * Loader rolling a rejected update back to the previous one) yields the same
+ * tree each time. Inserted entries are
  * indexed as they are added, so a later patch in the same list can target a
  * row an earlier patch inserted. A patch that matches nothing warns and is
  * skipped.
@@ -126,7 +127,7 @@ export function applyEntryPatches(
 
     for (const [key, value] of Object.entries(overrides)) {
       if (key === 'id') continue
-      target[key] = value
+      target[key] = typeof value === 'object' && value !== null ? structuredClone(value) : value
     }
   }