Explorar el Código

fix(boot): commit user-disabled rows with the composition and name a user row's trust

The runtime's user-disabled row ids now come from the committed composition —
composeProfileStack collects them from the user layers it composed — so a
rejected or half-written patch file no longer makes the list disagree with the
running tree or fail to parse. The inventory attributes a row disabled through
a group the user disabled to the user, through the shared userDisablesEntry
walk, and lists a user-layer row the composition left out under the new user
trust instead of builtin.
Yichen Jiang hace 2 semanas
padre
commit
20102bcd52

+ 0 - 4
apps/cli/src/profile-boot.ts

@@ -312,10 +312,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
       loadProfile: () => prepareProfile(options.profile),
       compose: composeFor,
       rootEntry: () => rootIncludeEntry(ctx),
-      readUserPatches: () => [
-        ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
-        ...loadOptionalPatches(NAME, homePatchPath()) ?? [],
-      ],
     })
     const runtime = ctx.get('profileRuntime')
     if (runtime !== undefined) app.runtime = runtime

+ 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: 44975507f4a31aae49b644593a4dcc74864df629
-core.zh.md: b91f236cf4d65928e5806d8d30d75c23d7b5f703
+core.md: 4028b19620e72a3c39164444453bb5fc1f818d7f
+core.zh.md: 2c75c867db13173c6db34b8a83f47c3f10f28972

+ 6 - 6
docs/subsystems/core.md

@@ -853,13 +853,13 @@ Facts and recomposition of the booted profile.
 originOf(rowId: string): RowOrigin | undefined
 
 /**
- * Row ids the user patch layers disable with a literal `disabled: true`.
- * A `!!js` gate in a user file stays an expression node when read from
- * disk, so it is a condition, not a user decision, and is left to the
- * composition.
- * @returns the ids, re-read from disk on every call.
+ * Row ids the user patch layers disable with a literal `disabled: true`,
+ * as the committed composition read them. The set describes the running
+ * tree: a user file the include rejected, or one that cannot be parsed,
+ * changes nothing here until a composition with it is accepted.
+ * @returns the ids, from the committed composition.
  */
-userDisabledRowIds(): Set<string>
+userDisabledRowIds(): ReadonlySet<string>
 
 /**
  * Recompose the host tree from the profile's layers and the user patch files

+ 6 - 6
docs/subsystems/core.zh.md

@@ -863,13 +863,13 @@ Facts and recomposition of the booted profile.
 originOf(rowId: string): RowOrigin | undefined
 
 /**
- * Row ids the user patch layers disable with a literal `disabled: true`.
- * A `!!js` gate in a user file stays an expression node when read from
- * disk, so it is a condition, not a user decision, and is left to the
- * composition.
- * @returns the ids, re-read from disk on every call.
+ * Row ids the user patch layers disable with a literal `disabled: true`,
+ * as the committed composition read them. The set describes the running
+ * tree: a user file the include rejected, or one that cannot be parsed,
+ * changes nothing here until a composition with it is accepted.
+ * @returns the ids, from the committed composition.
  */
-userDisabledRowIds(): Set<string>
+userDisabledRowIds(): ReadonlySet<string>
 
 /**
  * Recompose the host tree from the profile's layers and the user patch files

+ 10 - 1
packages/boot/app-boot/src/compose-stack.ts

@@ -58,6 +58,12 @@ export interface ComposedStack {
   readonly conflicts: RowConflict[]
   /** External bundles left out because a row id was already claimed or repeated. */
   readonly skippedBundles: string[]
+  /**
+   * Row ids the user layers disable with a literal `disabled: true`, as
+   * composed; a `!!js` gate stays an expression node when read from disk, so
+   * it is a condition of the composition, not a user decision.
+   */
+  readonly userDisabledRowIds: ReadonlySet<string>
 }
 
 /** Row-id ownership across the bundle layers: who owns each id, which external bundles lost, and how the rest mount. */
@@ -166,7 +172,7 @@ export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
  * @param binName - the diagnostic prefix on a thrown built-in duplicate.
  * @param layers - the profile's bundle layers, in manifest order.
  * @param userLayers - the user-owned layers, in application order.
- * @returns the patches to mount, the owner of every bundle id, the conflicts, and the bundles left out.
+ * @returns the patches to mount, the owner of every bundle id, the conflicts, the bundles left out, and the rows the user layers disable.
  * @throws when two built-in or boot-staged layers declare the same id, or one of them declares an id twice.
  */
 export function composeProfileStack(
@@ -193,10 +199,12 @@ export function composeProfileStack(
   }
   const claimed = new Map<string, string>()
   for (const [id, layer] of ownership.owners) claimed.set(id, layer.packageName)
+  const userDisabledRowIds = new Set<string>()
   for (const userLayer of userLayers) {
     const patches: PatchOptions[] = []
     for (const patch of userLayer.patches) {
       if (patch.insert === undefined) {
+        if (patch.id !== undefined && patch.disabled === true) userDisabledRowIds.add(patch.id)
         patches.push(patch)
         continue
       }
@@ -222,6 +230,7 @@ export function composeProfileStack(
     owners: ownership.owners,
     conflicts,
     skippedBundles,
+    userDisabledRowIds,
   }
 }
 

+ 1 - 0
packages/boot/app-boot/src/index.ts

@@ -65,6 +65,7 @@ export {
 } from './compose-stack.ts'
 export {
   ProfileRuntime, type ProfileRuntimeOptions, type RowOrigin,
+  userDisablesEntry,
 } from './profile-runtime.ts'
 export {
   PLUGIN_PROBE_DIR, PLUGIN_PROBE_FORMAT, probePackage, readProbeCache, writeProbeCache,

+ 23 - 15
packages/boot/app-boot/src/profile-runtime.ts

@@ -18,7 +18,6 @@
 import { Context, Service } from '@deepseek-ai/cordis'
 import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
 import type Include from '@deepseek-ai/cordis-plugin-include'
-import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import type { ProfilePatchReload } from '@deepseek-ai/dsh-package-manifest'
 import type { ComposedStack, RowConflict } from './compose-stack.ts'
 import type { BundleTrust, Profile, ProfileLayer } from './profile.ts'
@@ -52,8 +51,6 @@ export interface ProfileRuntimeOptions {
   compose: (profile: Profile) => ComposedStack
   /** The root Include entry, once mounted. */
   rootEntry: () => Entry | undefined
-  /** The user patch layers as they stand on disk (profile file, then home file). */
-  readUserPatches: () => PatchOptions[]
 }
 
 /** The profile and stack the tree runs, published together once the root include accepted the stack. */
@@ -124,19 +121,14 @@ export class ProfileRuntime extends Service {
   }
 
   /**
-   * Row ids the user patch layers disable with a literal `disabled: true`.
-   * A `!!js` gate in a user file stays an expression node when read from
-   * disk, so it is a condition, not a user decision, and is left to the
-   * composition.
-   * @returns the ids, re-read from disk on every call.
+   * Row ids the user patch layers disable with a literal `disabled: true`,
+   * as the committed composition read them. The set describes the running
+   * tree: a user file the include rejected, or one that cannot be parsed,
+   * changes nothing here until a composition with it is accepted.
+   * @returns the ids, from the committed composition.
    */
-  userDisabledRowIds(): Set<string> {
-    const ids = new Set<string>()
-    for (const patch of this.options.readUserPatches()) {
-      if (patch.insert !== undefined || patch.id === undefined) continue
-      if (patch.disabled === true) ids.add(patch.id)
-    }
-    return ids
+  userDisabledRowIds(): ReadonlySet<string> {
+    return this.committed.stack.userDisabledRowIds
   }
 
   /**
@@ -176,3 +168,19 @@ export class ProfileRuntime extends Service {
     this.committed = { profile, stack }
   }
 }
+
+/**
+ * Whether the user patch layers disable an entry: its own row id, or the id
+ * of a group holding it, is in the set. The Loader disables every descendant
+ * of a disabled group, so a child's own id alone does not say who switched
+ * it off.
+ * @param entry - the Loader entry.
+ * @param userDisabled - the ids the user layers disable, from `userDisabledRowIds()`.
+ * @returns true when the user's patches disable the entry or one of the groups holding it.
+ */
+export function userDisablesEntry(entry: Entry, userDisabled: ReadonlySet<string>): boolean {
+  for (let current: Entry | undefined = entry; current !== undefined; current = current.parent.ctx.fiber.entry) {
+    if (typeof current.options.id === 'string' && userDisabled.has(current.options.id)) return true
+  }
+  return false
+}

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

@@ -133,6 +133,17 @@ describe('claimLayerIds', () => {
 })
 
 describe('composeProfileStack', () => {
+  it('collects the rows the user layers disable with a literal disabled: true', () => {
+    const stack = composeProfileStack(NAME, [base], [{ label: '/p/cordis.patch.yml', patches: [
+      { id: 'a', disabled: true },
+      // A gate read from disk is an expression node: a condition of the composition, not a decision.
+      { id: 'b', disabled: { __jsExpr: 'true' } as unknown as boolean },
+      { id: 'c', config: {} },
+      { insert: [{ id: 'd', name: 'x', disabled: true }] },
+    ] }])
+    expect([...stack.userDisabledRowIds]).toEqual(['a'])
+  })
+
   it('mounts owning layers in manifest order and drops a user insert of a taken id', () => {
     const ext = layer('ext', 'external', [{ insert: [{ id: 'ext-tool', name: 'ext' }] }])
     const stack = composeProfileStack(NAME, [base, ext], [

+ 21 - 14
packages/boot/app-boot/tests/profile-runtime.spec.ts

@@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import type { Entry, EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import { claimLayerIds, ProfileRuntime, type ComposedStack, type Profile, type ProfileLayer } from '../src/index.ts'
+import { claimLayerIds, ProfileRuntime, userDisablesEntry, type ComposedStack, type Profile, type ProfileLayer } from '../src/index.ts'
 
 const contexts: Context[] = []
 afterEach(async () => {
@@ -24,7 +24,7 @@ function profile(layers: ProfileLayer[]): Profile {
 
 async function harness(
   layers: ProfileLayer[],
-  options: { rootEntry?: () => Entry | undefined; userPatches?: PatchOptions[]; reloaded?: Profile; conflicts?: ComposedStack['conflicts'] } = {},
+  options: { rootEntry?: () => Entry | undefined; reloaded?: Profile; conflicts?: ComposedStack['conflicts'] } = {},
 ): Promise<{ ctx: Context; runtime: ProfileRuntime; compose: ReturnType<typeof vi.fn> }> {
   const ctx = new Context()
   contexts.push(ctx)
@@ -37,6 +37,7 @@ async function harness(
       owners: claimLayerIds(current.layers).owners,
       conflicts: current === options.reloaded ? options.conflicts ?? [] : [],
       skippedBundles: [],
+      userDisabledRowIds: new Set(current === options.reloaded ? ['reloaded-off'] : ['booted-off']),
     }
   })
   const booted = profile(layers)
@@ -46,7 +47,6 @@ async function harness(
     loadProfile: () => options.reloaded ?? profile(layers),
     compose,
     rootEntry: options.rootEntry ?? (() => undefined),
-    readUserPatches: () => options.userPatches ?? [],
   })
   return { ctx, runtime: ctx.profileRuntime, compose }
 }
@@ -91,16 +91,22 @@ describe('ProfileRuntime', () => {
     expect(runtime.originOf('r')).toEqual({ trust: 'builtin', packageName: 'local' })
   })
 
-  it('reads user-disabled rows from literal disabled: true items only', async () => {
-    const { runtime } = await harness([], {
-      userPatches: [
-        { id: 'a', disabled: true },
-        { id: 'b', disabled: { __jsExpr: 'true' } as unknown as boolean },
-        { id: 'c', config: {} },
-        { insert: [{ id: 'd', name: 'x', disabled: true }] },
-      ],
-    })
-    expect([...runtime.userDisabledRowIds()]).toEqual(['a'])
+  it('reports the user-disabled rows of the committed composition and keeps them when the include rejects an 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', [])])
+    const { runtime } = await harness([layer('a', 'builtin', [])], { rootEntry: () => entry, reloaded })
+    expect([...runtime.userDisabledRowIds()]).toEqual(['booted-off'])
+    await expect(runtime.recompose({ reloadBundles: true })).rejects.toThrow('rejected')
+    expect([...runtime.userDisabledRowIds()]).toEqual(['booted-off'])
+  })
+
+  it('tells a row the user disabled through a group holding it from one the composition gates', () => {
+    const chain = (ids: string[]): Entry => ids.reduceRight<Entry | undefined>(
+      (parent, id) => ({ options: { id }, parent: { ctx: { fiber: { entry: parent } } } } as unknown as Entry), undefined,
+    ) as Entry
+    expect(userDisablesEntry(chain(['kid', 'grp', 'root']), new Set(['grp']))).toBe(true)
+    expect(userDisablesEntry(chain(['kid', 'grp']), new Set(['kid']))).toBe(true)
+    expect(userDisablesEntry(chain(['kid', 'grp']), new Set(['other']))).toBe(false)
   })
 
   it('recomposes through the root include, optionally re-reading the profile first, and commits on acceptance', async () => {
@@ -118,9 +124,10 @@ describe('ProfileRuntime', () => {
     await runtime.recompose({ reloadBundles: true })
     expect(runtime.layers).toHaveLength(2)
     expect(update).toHaveBeenLastCalledWith({ config: { path: 'file:///root/cordis.yml', patches: [{ id: 'composed-for-2' }] } })
-    // Provenance and conflicts follow the reloaded profile once the update holds.
+    // Provenance, conflicts, and the user-disabled rows follow the reloaded profile once the update holds.
     expect(runtime.originOf('bundle/b')).toEqual({ trust: 'external', packageName: 'b', version: '2.0.0' })
     expect(runtime.conflicts).toEqual(conflicts)
+    expect([...runtime.userDisabledRowIds()]).toEqual(['reloaded-off'])
   })
 
   it('runs recompositions one at a time, each from what the previous one committed', async () => {

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

@@ -1398,10 +1398,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         returns: 'the origin, or undefined for a row no bundle layer owns (a user or overlay row, or a bundle left out by a conflict).',
       },
       {
-        signature: 'userDisabledRowIds(): Set<string>',
-        description: 'Row ids the user patch layers disable with a literal `disabled: true`. A `!!js` gate in a user file stays an expression node when read from disk, so it is a condition, not a user decision, and is left to the composition.',
+        signature: 'userDisabledRowIds(): ReadonlySet<string>',
+        description: 'Row ids the user patch layers disable with a literal `disabled: true`, as the committed composition read them. The set describes the running tree: a user file the include rejected, or one that cannot be parsed, changes nothing here until a composition with it is accepted.',
         parameters: [],
-        returns: 'the ids, re-read from disk on every call.',
+        returns: 'the ids, from the committed composition.',
       },
       {
         signature: 'async recompose(options: { reloadBundles?: boolean } = {}): Promise<void>',

+ 2 - 2
packages/host/plugin-inventory/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/host/plugin-inventory/README.md
-README.md: 0eaf395ec9ecbdaf56c26c9adc8498b0e6ebaaec
-README.zh.md: 60140b175c7f4f7b28cd35e710b92af4c2deaa5d
+README.md: ca8055907433fd01d22a032a4301970457f8d637
+README.zh.md: 9d3cc3414028ca3fae3d829869283e214c36fdc8

+ 1 - 1
packages/host/plugin-inventory/README.md

@@ -31,7 +31,7 @@ Call `pluginInventory/list` when a client or settings page needs to show what is
 
 Each row is one non-group Loader entry: its entry id, the exact module specifier, the effective enablement (including disabled ancestor groups), and the current root Fiber phase. `pending` means the entry waits to load, `loading` that it is being read, `active` that it is running, `failed` that its fiber rejected, and `unloading` that it is being torn down; `null` means no live root Fiber exists at all. Structural group rows are skipped.
 
-When the profile launcher composed the tree, each row also says who supplied it: `trust` is `builtin` for a row of the installation's own bundles and `external` for a row of a bundle the user installed, `package` names that bundle with its version and, for an external row, the id the bundle's own patch declared before the launcher prefixed it, and a disabled row carries `disabledBy` — `user` when a user patch file disabled it with a literal `disabled: true`, `composition` for a bundle's own gate or tombstone. A row an isolated external bundle failed to start is gone from the tree; it is still listed, with `fiberPhase: 'failed'` and a `failure` naming the stage and message, from the launcher's failure registry. Without the launcher every row reads `builtin` and none carries a package or failure.
+When the profile launcher composed the tree, each row also says who supplied it: `trust` is `builtin` for a row of the installation's own bundles, `external` for a row of a bundle the user installed, and `user` for a row the user's own patch file inserted that the composition left out, `package` names that bundle with its version and, for an external row, the id the bundle's own patch declared before the launcher prefixed it, and a disabled row carries `disabledBy` — `user` when a user patch file disabled it, or a group holding it, with a literal `disabled: true`, `composition` for a bundle's own gate or tombstone. A row an isolated external bundle failed to start is gone from the tree; it is still listed, with `fiberPhase: 'failed'` and a `failure` naming the stage and message, from the launcher's failure registry. Without the launcher every row reads `builtin` and none carries a package or failure.
 
 ### Per-preset compositions
 

+ 1 - 1
packages/host/plugin-inventory/README.zh.md

@@ -31,7 +31,7 @@ kind: "package-reference"
 
 每一行是一个非组 Loader 条目:其条目 id、精确模块标识、有效启用状态(含被禁用的祖先组)与当前根 Fiber 阶段。`pending` 表示条目等待加载,`loading` 表示正在读取,`active` 表示正在运行,`failed` 表示其 fiber 被拒绝,`unloading` 表示正在拆除;`null` 表示完全不存在存活的根 Fiber。结构性的 group 行会被跳过。
 
-当树由 profile launcher 组合时,每一行还会说明是谁提供的:`trust` 对安装自带组合包的行是 `builtin`,对用户安装的组合包的行是 `external`;`package` 给出该组合包的名称与版本,对外部行还给出组合包自己的 patch 在 launcher 加前缀之前声明的 id;停用的行带 `disabledBy`——用户 patch 文件用字面量 `disabled: true` 停用的是 `user`,组合包自己的门或墓碑是 `composition`。被隔离的外部组合包启动失败的行已经不在树里;它仍从 launcher 的失败注册表列出,`fiberPhase` 为 `'failed'`,并带一个说明阶段与消息的 `failure`。没有 launcher 时每一行都读作 `builtin`,也没有 package 或 failure。
+当树由 profile launcher 组合时,每一行还会说明是谁提供的:`trust` 对安装自带组合包的行是 `builtin`,对用户安装的组合包的行是 `external`,对用户自己的补丁文件插入却被组合排除的行是 `user`;`package` 给出该组合包的名称与版本,对外部行还给出组合包自己的 patch 在 launcher 加前缀之前声明的 id;停用的行带 `disabledBy`——用户 patch 文件用字面量 `disabled: true` 停用它、或停用了持有它的组的是 `user`,组合包自己的门或墓碑是 `composition`。被隔离的外部组合包启动失败的行已经不在树里;它仍从 launcher 的失败注册表列出,`fiberPhase` 为 `'failed'`,并带一个说明阶段与消息的 `failure`。没有 launcher 时每一行都读作 `builtin`,也没有 package 或 failure。
 
 ### 每个预设的组合
 

+ 3 - 3
packages/host/plugin-inventory/src/index.ts

@@ -6,7 +6,7 @@ import type {} from '@deepseek-ai/cordis-plugin-loader'
 import type {} from '@deepseek-ai/dsh-agent-presets'
 // Type-only: the optional profile runtime and contained-failure registry the
 // boot glue provides, both resolved through `ctx.get`.
-import type {} from '@deepseek-ai/dsh-app-boot'
+import { userDisablesEntry } from '@deepseek-ai/dsh-app-boot'
 import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
 // Typert-generated ./typert and ./remote artifacts import Zod at runtime.
 import type {} from 'zod'
@@ -96,7 +96,7 @@ export class PluginInventoryGateway extends TypertRemoteService {
         fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state],
         trust: origin?.trust ?? 'builtin',
         ...origin === undefined ? {} : { package: packageRef(origin) },
-        ...enabled ? {} : { disabledBy: userDisabled.has(entry.options.id) ? 'user' as const : 'composition' as const },
+        ...enabled ? {} : { disabledBy: userDisablesEntry(entry, userDisabled) ? 'user' as const : 'composition' as const },
         ...failure === undefined ? {} : { failure: { stage: failure.stage, message: failure.message } },
       })
     }
@@ -125,7 +125,7 @@ export class PluginInventoryGateway extends TypertRemoteService {
           moduleName: conflict.moduleName,
           enabled: true,
           fiberPhase: 'failed',
-          trust: conflict.packageName === undefined ? 'builtin' : 'external',
+          trust: conflict.packageName === undefined ? 'user' : 'external',
           ...conflict.packageName === undefined
             ? {}
             : { package: packageRef({ packageName: conflict.packageName, ...version === undefined ? {} : { version } }) },

+ 5 - 2
packages/host/plugin-inventory/src/types.ts

@@ -12,8 +12,11 @@ export type PluginFiberPhase =
   | 'unloading'
   | null
 
-/** Who supplied a Loader row: the installation itself or an installed external bundle. */
-export type PluginTrust = 'builtin' | 'external'
+/**
+ * Who supplied a Loader row: the installation itself, an installed external
+ * bundle, or the user's own patch file (a row of theirs the composition left out).
+ */
+export type PluginTrust = 'builtin' | 'external' | 'user'
 
 /** Why a row is disabled: the composition's own gate or tombstone, or the user's patch layer. */
 export type PluginDisabledBy = 'composition' | 'user'

+ 16 - 2
packages/host/plugin-inventory/tests/inventory.spec.ts

@@ -1,6 +1,6 @@
 import { afterEach, describe, expect, it } from 'vitest'
 import { Context, FiberState, type Plugin } from '@deepseek-ai/cordis'
-import Loader from '@deepseek-ai/cordis-plugin-loader'
+import Loader, { Group } from '@deepseek-ai/cordis-plugin-loader'
 import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
 import type { AgentPresets } from '@deepseek-ai/dsh-agent-presets'
 import { ensurePluginFailures, type ProfileRuntime, type RowOrigin } from '@deepseek-ai/dsh-app-boot'
@@ -143,7 +143,7 @@ describe('PluginInventoryGateway', () => {
         package: { name: 'late', version: '9.9.9' }, failure: { stage: 'conflict', message: 'row "tool" is already declared by ext' },
       },
       {
-        entryId: 'conflict:/p/cordis.patch.yml:mine', moduleName: 'twice', enabled: true, fiberPhase: 'failed', trust: 'builtin',
+        entryId: 'conflict:/p/cordis.patch.yml:mine', moduleName: 'twice', enabled: true, fiberPhase: 'failed', trust: 'user',
         failure: { stage: 'conflict', message: 'row "mine" is already declared by ext' },
       },
       // A bundle the layer list no longer names keeps its package, without a version.
@@ -154,6 +154,20 @@ describe('PluginInventoryGateway', () => {
     ].sort(byId))
   })
 
+  it('attributes a row disabled through a group the user disabled to the user', async () => {
+    const { ctx, inventory } = await harness()
+    ctx.loader.builtins.group = Group
+    const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [{ name: 'cordis:active' }] })
+    const child = [...ctx.loader.entries()].find(entry => !entry.options.group && entry.parent.ctx.fiber.entry?.options.id === groupId)
+    expect(child).toBeDefined()
+    ctx.provide('profileRuntime', {
+      originOf: () => undefined, userDisabledRowIds: () => new Set([groupId]), layers: [], conflicts: [],
+    } as unknown as ProfileRuntime)
+    await ctx.loader.update(groupId, { disabled: true })
+    const listed = (await inventory.list()).entries.find(entry => entry.entryId === child?.id)
+    expect(listed).toMatchObject({ enabled: false, disabledBy: 'user' })
+  })
+
   it('carries each composed preset with root-fiber states mapped to phases', async () => {
     const { ctx, inventory } = await harness()
     ctx.provide('agentPresets', {

+ 1 - 0
scripts/gen-cordis-catalog.ts

@@ -673,6 +673,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
   'Record',
   'Readonly',
   'Set',
+  'ReadonlySet',
   'Uint8Array',
 ])