Explorar o código

merge: restore startup requirements across plugin management UI

Yichen Jiang hai 1 mes
pai
achega
e812aa66b1
Modificáronse 52 ficheiros con 280 adicións e 546 borrados
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.i18n.yaml
  2. 3 3
      .agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md
  3. 3 3
      .agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md
  4. 1 1
      apps/cli/src/dump-config.ts
  5. 3 3
      apps/cli/src/profile-boot.ts
  6. 5 9
      apps/cli/tests/built-bin.e2e.ts
  7. 1 1
      apps/cli/tests/fixtures/invalid-provider.cordis.yml
  8. 1 2
      apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts
  9. 6 7
      apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts
  10. 1 8
      apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts
  11. 0 38
      apps/cli/tests/startup-bundle.ts
  12. 0 2
      apps/web/tests/scaffold.ts
  13. 2 2
      docs/capability-seams.i18n.yaml
  14. 1 1
      docs/capability-seams.md
  15. 1 1
      docs/capability-seams.zh.md
  16. 2 2
      docs/event-producer-consumer.md
  17. 2 2
      docs/user/develop/basic/publish.i18n.yaml
  18. 1 1
      docs/user/develop/basic/publish.md
  19. 1 1
      docs/user/develop/basic/publish.zh.md
  20. 2 2
      packages/boot/app-boot/README.i18n.yaml
  21. 4 4
      packages/boot/app-boot/README.md
  22. 4 4
      packages/boot/app-boot/README.zh.md
  23. 16 61
      packages/boot/app-boot/src/compose-stack.ts
  24. 0 9
      packages/boot/app-boot/src/external-bundles.ts
  25. 25 17
      packages/boot/app-boot/src/index.ts
  26. 2 8
      packages/boot/app-boot/src/profile-runtime.ts
  27. 5 49
      packages/boot/app-boot/src/profile.ts
  28. 3 7
      packages/boot/app-boot/tests/app-boot.spec.ts
  29. 42 40
      packages/boot/app-boot/tests/compose-stack.spec.ts
  30. 34 34
      packages/boot/app-boot/tests/entry-issues.spec.ts
  31. 8 9
      packages/boot/app-boot/tests/external-bundles.spec.ts
  32. 25 25
      packages/boot/app-boot/tests/profile-runtime.spec.ts
  33. 2 53
      packages/boot/app-boot/tests/profile.spec.ts
  34. 2 2
      packages/boot/plugin-manager/README.i18n.yaml
  35. 1 1
      packages/boot/plugin-manager/README.md
  36. 1 1
      packages/boot/plugin-manager/README.zh.md
  37. 2 2
      packages/boot/plugin-manager/src/installer.ts
  38. 1 9
      packages/boot/plugin-manager/src/types.ts
  39. 1 7
      packages/boot/plugin-manager/src/view.ts
  40. 41 42
      packages/boot/plugin-manager/tests/plugin-manager.spec.ts
  41. 0 1
      packages/bundle/web-app/tests/web-app.spec.ts
  42. 2 18
      packages/extensions/tool-cordis/src/api-catalog.ts
  43. 2 2
      packages/host/plugin-inventory/README.i18n.yaml
  44. 1 1
      packages/host/plugin-inventory/README.md
  45. 1 1
      packages/host/plugin-inventory/README.zh.md
  46. 0 2
      packages/host/plugin-inventory/src/index.ts
  47. 0 8
      packages/host/plugin-inventory/src/types.ts
  48. 16 16
      packages/host/plugin-inventory/tests/inventory.spec.ts
  49. 0 2
      packages/test-support/loader-smoke/tests/fixtures/production-profile.ts
  50. 0 1
      packages/util/package-manifest/src/index.ts
  51. 0 18
      packages/util/package-manifest/src/types.ts
  52. 1 1
      scripts/gen-doc-graphs.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.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-11-native-entry-diagnostics-and-static-plugin-declarations.md
-2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md: 72e537a31c7ca8e770991ed5f70fc7c922873305
-2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md: d14acc21cfe9d04f57920824068baac8cb2573c4
+2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md: 773e8ea8df979cfc6ba5426cbc637f7facea03cb
+2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md: 32a9b7c4b30c2681019dc407a8b1ea43ad65f58f

+ 3 - 3
.agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md

@@ -10,7 +10,7 @@ Third-party plugin failures must leave the application’s management endpoints
 
 ## Decision
 
-**Startup strictness belongs to row provenance.** Only rows introduced by an external runtime bundle are optional. Built-in, boot-staged, unowned rows and the bootstrap Include are required. Profile stage overrides author stage, which defaults to runtime; first-party declarations and template provenance determine trust. Stage changes failure policy, not execution timing. A config override retains the target row’s owner. Nested Includes inherit their owning entry’s provenance; their local ids are never resolved against unrelated root rows.
+**Startup strictness remains consumer-owned.** App-boot uses the [global required entry ids](2026-09-09-consumer-owned-startup-strictness.md) and the bootstrap Include identity. Package origin does not change the audit: all other rows may fail with a warning. Bundle metadata has no trust or stage policy. Row ownership only attributes diagnostics and management operations; nested Includes inherit their owning entry’s package without matching unrelated root ids.
 
 **Bundle enablement selects a whole patch layer.** Native rows and author-written groups retain their explicit ids and parents. Anonymous bundle rows receive deterministic ids on detached execution copies. Disabling a bundle removes its inserts and overrides across every target group; persistent user row overrides survive re-enablement. Ownership is checked before loading: strict layers claim ids first, optional collisions omit the whole losing bundle, and conflicting user inserts are omitted per row. Prefixing explicit ids would break author expressions and user patches; relying on authors to avoid collisions permits silent Loader reparenting.
 
@@ -28,7 +28,7 @@ Third-party plugin failures must leave the application’s management endpoints
 
 **Import inside the Host with try/catch.** Rejected because discovery could execute irreversible side effects, exit the Host or block its event loop. Catching exceptions cannot restore that isolation. Parsing source to infer plugin exports would add another JavaScript interpreter with incomplete results.
 
-**Use a global list of required plugin ids.** Rejected because ids do not describe provenance, external boot providers, custom profiles or nested Include trees. Existing profile trust and stage declarations already own those distinctions.
+**Select startup strictness from package provenance.** Rejected because installation source does not establish whether an application endpoint can work. The consumer-owned required-id list already captures that requirement, including providers whose failure leaves a required consumer pending.
 
 **Recover by rolling back every live update or ignoring all post-boot rejections.** Neither can establish a safe restored state after plugin effects run. Per-row outcomes and retained user choices are observable; process-level detached failures cannot be attributed reliably to a row and remain fatal.
 
@@ -38,6 +38,6 @@ Installed metadata, enabled layer selection and current runtime health are indep
 
 ## Verification
 
-`packages/boot/app-boot/tests/entry-issues.spec.ts` covers trust and stage, import/config/apply/disabled/pending failures, nested and anonymous provenance, active old config after failed updates, recovery, and awaited removed-fiber teardown. Composition tests cover duplicate ownership and unchanged parents. Static metadata tests use an import-time file-writing fixture and assert no execution, retain unknown packages, and reject malformed declarations. Inventory tests verify actual Loader failures and composition conflicts without a historical failure registry. Process-guard and user-patch tests retain fatal detached failures and best-effort live reload diagnostics.
+`packages/boot/app-boot/tests/entry-issues.spec.ts` covers required-id policy, import/config/apply/disabled/pending failures, nested and anonymous provenance, active old config after failed updates, recovery, and awaited removed-fiber teardown. Composition tests cover duplicate ownership and unchanged parents. Static metadata tests use an import-time file-writing fixture and assert no execution, retain unknown packages, and reject malformed declarations. Inventory tests verify actual Loader failures and composition conflicts without a historical failure registry. Process-guard and user-patch tests retain fatal detached failures and best-effort live reload diagnostics.
 
 The [manager/Remote split](2026-09-04-plugin-manager-over-the-profile-runtime.md) remains in force. Manager integration tests cover retained unknown packages, static declarations, per-entry enable results, failed overrides with original ownership, and runtime-change notifications after dependency recovery.

+ 3 - 3
.agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md

@@ -10,7 +10,7 @@ Status: implemented
 
 ## Decision
 
-**启动严格程度由行来源决定。** 只有外部 runtime 组合包引入的行是可选行。内置、boot 阶段、无归属行以及启动 Include 均为必需。profile 的 stage 覆盖作者声明,默认值为 runtime;firstParty 声明与模板来源决定 trust。stage 改变失败策略,不改变执行时间。配置覆盖保留目标行的所有者。嵌套 Include 继承所属条目的来源,不能用其局部 id 查询无关的根级行。
+**启动严格程度仍由消费者负责。** App-boot 使用[全局必需条目 id](2026-09-09-consumer-owned-startup-strictness.zh.md) 和启动 Include 的身份。包来源不改变审查规则:其他行失败只产生警告。组合包元数据没有 trust 或 stage 策略。行归属只用于诊断和管理操作;嵌套 Include 继承所属条目的包,不能匹配无关的根级 id。
 
 **组合包启停选择整份 patch 层。** 原生行和作者声明的组保留显式 id 与父组。匿名组合包行在独立执行副本中获得确定性的 id。禁用组合包会从所有目标组中移除其插入与覆盖;持久化的用户行覆盖在重新启用后保留。加载前检查所有权:严格层优先占有 id,可选层冲突时整包排除,用户插入冲突时逐行排除。为显式 id 加前缀会破坏作者表达式与用户 patch;依赖作者自行避免冲突则允许 Loader 静默改变父组。
 
@@ -28,7 +28,7 @@ Status: implemented
 
 **在 Host 内 import 并 try/catch。** 拒绝,因为发现阶段可能产生不可逆副作用、退出 Host 或阻塞事件循环。捕获异常无法恢复隔离。通过解析源码推断插件导出会增加一个结果不完整的 JavaScript 解释器。
 
-**使用全局必需插件 id 列表。** 拒绝,因为 id 无法描述来源、外部 boot 提供方、自定义 profile 或嵌套 Include 树。已有的 profile trust 与 stage 声明负责这些区别。
+**通过包来源选择启动严格程度。** 拒绝,因为安装来源不能说明应用入口是否可用。消费者拥有的必需 id 列表已表达该要求,也包括提供方失败导致必需消费者等待的情况。
 
 **通过回滚全部实时更新或忽略所有启动后 rejection 恢复。** 插件副作用发生后,两者都不能保证恢复安全状态。逐行结果与保留的用户选择可以被观测;进程级脱离管理失败无法可靠归属到某行,因此仍然致命。
 
@@ -38,6 +38,6 @@ Status: implemented
 
 ## Verification
 
-`packages/boot/app-boot/tests/entry-issues.spec.ts` 覆盖 trust 与 stage、导入/配置/apply/disabled/pending 失败、嵌套与匿名来源、失败更新后的活跃旧配置、恢复及已移除 fiber 的清理等待。组合测试覆盖重复归属与父组保持。静态元信息测试使用导入时写文件的 fixture,确认未执行、保留未知包并拒绝无效声明。inventory 测试直接验证 Loader 失败与组合冲突,不依赖历史失败注册表。进程处理与用户 patch 测试保留脱离管理失败的致命策略,以及尽力执行的实时重载诊断。
+`packages/boot/app-boot/tests/entry-issues.spec.ts` 覆盖必需 id 策略、导入/配置/apply/disabled/pending 失败、嵌套与匿名来源、失败更新后的活跃旧配置、恢复及已移除 fiber 的清理等待。组合测试覆盖重复归属与父组保持。静态元信息测试使用导入时写文件的 fixture,确认未执行、保留未知包并拒绝无效声明。inventory 测试直接验证 Loader 失败与组合冲突,不依赖历史失败注册表。进程处理与用户 patch 测试保留脱离管理失败的致命策略,以及尽力执行的实时重载诊断。
 
 [管理器与 Remote 的拆分](2026-09-04-plugin-manager-over-the-profile-runtime.zh.md)继续适用。管理器集成测试覆盖未知包保留、静态声明、逐行启用结果、保留原归属的失败覆盖,以及依赖恢复后的运行变化通知。

+ 1 - 1
apps/cli/src/dump-config.ts

@@ -55,7 +55,7 @@ export function runDumpConfig(
   }
   // The same composition boot mounts: bundle layers in their declared order,
   // a bundle or row left out by an id conflict reported, not shown.
-  const stack = composeProfileStack(NAME, loaded.layers, userLayers)
+  const stack = composeProfileStack(loaded.layers, userLayers)
   for (const conflict of stack.conflicts) process.stderr.write(`${NAME}: ${formatRowConflict(conflict)}\n`)
   const layers: ConfigDumpLayer[] = stack.layers.map(layer => ({ label: layer.label, patches: layer.patches }))
   // The dump anchors on the same empty root file the boot includes.

+ 3 - 3
apps/cli/src/profile-boot.ts

@@ -244,7 +244,7 @@ async function composeProfile(
   const overlays: StackUserLayer[] = patchFiles.map(file => ({
     label: `--patch ${resolve(file)}`, patches: loadOverlayPatches(NAME, resolve(file)),
   }))
-  const composed = composeProfileStack(NAME, profile.layers, [...userLayersOf(profile), ...overlays])
+  const composed = composeProfileStack(profile.layers, [...userLayersOf(profile), ...overlays])
   const rows = new Set<string>()
   for (const row of composeEntries([composed.patches])) {
     if (typeof row.id === 'string') rows.add(row.id)
@@ -253,7 +253,7 @@ async function composeProfile(
   if (telemetryPatch !== undefined) overlays.push({ label: 'DSH_TELEMETRY_DISABLED', patches: [telemetryPatch] })
   const stack = telemetryPatch === undefined
     ? composed
-    : composeProfileStack(NAME, profile.layers, [...userLayersOf(profile), ...overlays])
+    : composeProfileStack(profile.layers, [...userLayersOf(profile), ...overlays])
   return { profile, stack, overlays }
 }
 
@@ -340,7 +340,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
   // would bake a user override into the bundle's in-memory insert row, so
   // removing the override could never revert the row to the bundle default.
   const composeFor = (profile: Profile): ComposedStack => {
-    const stack = composeProfileStack(NAME, profile.layers, [...userLayersOf(profile), ...composed.overlays])
+    const stack = composeProfileStack(profile.layers, [...userLayersOf(profile), ...composed.overlays])
     return { ...stack, patches: structuredClone(stack.patches) }
   }
   // Every recomposition after boot — a watched user file, a bundle enabled

+ 5 - 9
apps/cli/tests/built-bin.e2e.ts

@@ -1,4 +1,3 @@
-import { stageStartupBundle } from './startup-bundle.ts'
 import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
@@ -412,8 +411,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       '',
     ].join('\n'))
     try {
-      stageStartupBundle(home, 'sdk', patch)
-      const result = await runBuiltBin(['--profile', 'sdk'], {
+      const result = await runBuiltBin(['--profile', 'sdk', '--patch', patch], {
         DSH_HOME: home,
         DSH_TELEMETRY_DISABLED: '1',
         DEEPSEEK_API_KEY: 'built-sdk-startup-failure-no-call',
@@ -759,20 +757,18 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     }
   }, SPAWN_TIMEOUT_MS + 30_000)
 
-  it('rejects an external bundle that corrupts a builtin provider', async () => {
+  it('keeps serving when an optional patch-overlay plugin fails', async () => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
     try {
-      stageStartupBundle(home, 'web', invalidProvider)
-      const result = await runBuiltBin(['--profile', 'web', '--port', '0', '--no-open'], {
+      const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider, '--port', '0', '--no-open'], {
         DSH_HOME: home,
         DSH_BROWSER_OPEN_TEST_EXIT_ON_READY: '1',
         DEEPSEEK_API_KEY: 'keyless-invalid-config',
         DSH_TELEMETRY_DISABLED: '1',
         NODE_OPTIONS: `--import=${webReadyExitHook}`,
       })
-      expect(result.code, result.stderr).toBe(1)
-      expect(result.stdout).not.toContain('dsh web: http://')
-      expect(result.stderr).toContain('required startup failure')
+      expect(result.code, result.stderr).toBe(0)
+      expect(result.stdout).toMatch(/^dsh web: http:\/\/127\.0\.0\.1:\d+\/\?token=[A-Za-z0-9_-]+$/u)
       expect(result.stderr).toContain('llm-pi-ai')
     } finally {
       rmSync(home, { recursive: true, force: true })

+ 1 - 1
apps/cli/tests/fixtures/invalid-provider.cordis.yml

@@ -1,4 +1,4 @@
-# Invalid override of a builtin provider: an external patch retains the target's required startup policy.
+# Invalid optional provider override used to prove best-effort startup continues.
 
 - id: llm-pi-ai
   config:

+ 1 - 2
apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts

@@ -17,7 +17,6 @@ import {
   scanZstdFrames,
 } from '@deepseek-ai/dsh-session-persistence-jsonl/src/zstd.ts'
 import { describe, expect, it } from 'vitest'
-import { stageStartupBundle } from '../../../startup-bundle.ts'
 
 const goldensDir = fileURLToPath(new URL('./expected/', import.meta.url))
 const goalScenarioDir = join(goldensDir, 'goal-tools')
@@ -278,10 +277,10 @@ describe('headless stream-json snapshots', () => {
       tempDirPrefix: 'headless-snapshot-startup-error-',
       binScript: dshBinScript,
       configPath: startupFailureConfigPath,
-      prepare: (cwd) => { stageStartupBundle(join(cwd, '.dsh'), 'headless', startupFailureConfigPath) },
       binArgs: [
         '--profile', 'headless',
         '--patch', headlessOverlayPath,
+        '--patch', startupFailureConfigPath,
         'Complete the task despite the unrelated startup failure.',
       ],
       tsconfigPath,

+ 6 - 7
apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts

@@ -8,7 +8,6 @@ import type { Readable } from 'node:stream'
 import { fileURLToPath, pathToFileURL } from 'node:url'
 import { execa } from 'execa'
 import { describe, expect, it } from 'vitest'
-import { stageStartupBundle } from '../../../startup-bundle.ts'
 
 const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
 const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
@@ -47,7 +46,6 @@ function createFixture(): Fixture {
     '}',
     '',
   ].join('\n'))
-  writeFileSync(join(root, 'schema-failure.mjs'), 'export const Config = { "~standard": { version: 1, vendor: "startup", validate() { return { issues: [{ message: "web schema failure" }] } } } }; export function apply() {}\n')
   writeFileSync(join(root, 'sync-failure.mjs'), 'export function apply() { throw new Error("web sync apply failure") }\n')
   writeFileSync(join(root, 'async-failure.mjs'), [
     'export async function apply() {',
@@ -63,9 +61,10 @@ function createFixture(): Fixture {
   ].join('\n'))
   const patch = join(root, 'failures.patch.yml')
   writeFileSync(patch, [
+    '- id: tool-todo',
+    '  disabled: false',
+    '  config: {}',
     '- insert:',
-    '    - id: web-probe-schema-failure',
-    `      name: ${pathToFileURL(join(root, 'schema-failure.mjs')).href}`,
     '    - id: web-probe-good',
     `      name: ${pathToFileURL(join(root, 'good.mjs')).href}`,
     '      config:',
@@ -135,10 +134,10 @@ async function waitForStartup(
 describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', () => {
   it('serves the full Web app while unrelated entries fail to start', async () => {
     const fixture = createFixture()
-    stageStartupBundle(fixture.home, 'web', fixture.patch)
     const child = execa(process.execPath, [
       dshBin,
       '--profile', 'web',
+      '--patch', fixture.patch,
       '--no-open',
       '--port', '0',
     ], {
@@ -170,7 +169,7 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', ()
       expect(html).toContain('__DSH_BOOT__')
       expect(readFileSync(fixture.events, 'utf8')).toBe('good apply\n')
       expect(startup.stderr).toContain('web-probe-import-failure')
-      expect(startup.stderr).toContain('web schema failure')
+      expect(startup.stderr).toContain('@deepseek-ai/dsh-tool-todo')
       expect(startup.stderr).toContain('web sync apply failure')
       expect(startup.stderr).toContain('web async apply failure')
       expect(startup.stderr).toContain('pending (waiting for service: webProbeMissingService)')
@@ -317,11 +316,11 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', ()
       `      name: ${pathToFileURL(plugin).href}`,
       '',
     ].join('\n'))
-    stageStartupBundle(fixture.home, 'web', fixture.patch)
     try {
       const result = await execa(process.execPath, [
         dshBin,
         '--profile', 'web',
+        '--patch', fixture.patch,
         '--no-open',
         '--port', '0',
       ], {

+ 1 - 8
apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts

@@ -8,7 +8,6 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
 import { execa } from 'execa'
 import { describe, expect, it } from 'vitest'
 import { FiberState } from '@deepseek-ai/cordis'
-import { stageStartupBundle } from '../../../startup-bundle.ts'
 
 const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
 const bin = join(repoRoot, 'apps/cli/lib/bin.js')
@@ -173,8 +172,6 @@ describe.skipIf(!built)('Web process failure matrix', () => {
     it.each(failures)(`${required ? 'required' : 'optional'} startup %s`, async (failure, diagnostic) => {
       const f = fixture()
       writeFileSync(f.patch, f.render(id, failure))
-      stageStartupBundle(f.home, 'web', f.patch, required ? 'boot' : 'runtime')
-      writeFileSync(f.patch, '[]\n')
       const app = start(f)
       try {
         if (required) {
@@ -363,11 +360,7 @@ describe.skipIf(!built)('Web process failure matrix', () => {
       const patch = JSON.stringify([{ insert: [f.observer, f.witness(0), {
         id: 'matrix-optional-http', name: pathToFileURL(plugin).href, config: { port: address.port },
       }] }]) + '\n'
-      if (phase === 'startup') {
-        writeFileSync(f.patch, patch)
-        stageStartupBundle(f.home, 'web', f.patch)
-        writeFileSync(f.patch, '[]\n')
-      }
+      if (phase === 'startup') writeFileSync(f.patch, patch)
       app = start(f)
       const running = app
       await app.serves()

+ 0 - 38
apps/cli/tests/startup-bundle.ts

@@ -1,38 +0,0 @@
-/** Installed bundle provenance for product-profile startup acceptance tests. */
-import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
-import { join } from 'node:path'
-import {
-  initProfile, loadOverlayPatches, PROFILE_TEMPLATES, readProfileManifest, writeProfileManifest, type ProfileLayer,
-} from '@deepseek-ai/dsh-app-boot'
-
-/**
- * Install a patch as a real profile dependency, preserving its module resolution anchors.
- * @param home - the scenario's isolated Harness home.
- * @param profile - the shipped profile under test.
- * @param patchFile - the fixture patch whose relative module names resolve at its source.
- * @param stage - whether this external bundle is required or optional at startup.
- */
-export function stageStartupBundle(
-  home: string,
-  profile: 'web' | 'sdk' | 'headless',
-  patchFile: string,
-  stage: ProfileLayer['stage'] = 'runtime',
-): void {
-  const dir = join(home, 'profiles', profile)
-  const template = PROFILE_TEMPLATES[profile]
-  if (template === undefined) throw new Error(`Missing shipped profile template: ${profile}`)
-  if (!existsSync(join(dir, 'package.json'))) initProfile(dir, template.bundles, template.patchReload)
-  const name = '@fixture/startup-policy'
-  const bundle = join(dir, 'node_modules', name)
-  mkdirSync(bundle, { recursive: true })
-  writeFileSync(join(bundle, 'package.json'), JSON.stringify({
-    name, version: '1.0.0', private: true, type: 'module', dsh: { bundle: { patch: './cordis.patch.json', stage } },
-  }))
-  writeFileSync(join(bundle, 'cordis.patch.json'), JSON.stringify(loadOverlayPatches('startup fixture', patchFile)))
-  const manifest = readProfileManifest('startup fixture', dir)
-  writeProfileManifest(dir, {
-    ...manifest,
-    dependencies: { ...manifest.dependencies, [name]: '1.0.0' },
-    dsh: { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles: [...manifest.dsh?.profile?.bundles ?? [], name] } },
-  })
-}

+ 0 - 2
apps/web/tests/scaffold.ts

@@ -681,8 +681,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
         version: undefined,
         packageDir,
         patchPath: join(packageDir, 'cordis.patch.yml'),
-        trust: 'builtin' as const,
-        stage: 'runtime' as const,
         patches: [],
       }
     }))

+ 2 - 2
docs/capability-seams.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/capability-seams.md
-capability-seams.md: 4f342d507392e707c593d73a3aa3e4c93c4f44f7
-capability-seams.zh.md: 290017439d5279dac5bf155ca7a2a703c96fdb67
+capability-seams.md: 78e70ed5d76d5e981b96eede2e82287b9627b3a3
+capability-seams.zh.md: 0df28d49fcf0cbd7a3963ada6c2663a85acc8f53

+ 1 - 1
docs/capability-seams.md

@@ -522,7 +522,7 @@ flowchart LR
 | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. |
 | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm. |
 | `ctx.pluginManager` | `core` | [`host-plugin-manager`](../packages/host/plugin-manager) | - | - | - | Installs, enables, disables, retries, and removes bundles through pnpm and the profile runtime, edits rows in the profile's or a preset's user layer, and folds manifest, probe, and tree facts into one view per package for the plugins Remote. |
-| `ctx.profileRuntime` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`host-plugin-inventory`](../packages/host/plugin-inventory), [`host-plugin-manager`](../packages/host/plugin-manager) | - | Provided by the profile launcher after boot: the composed bundle layers with their trust and stage, each row's inserting layer, the rows user patch files disable, and the one recomposition path user patch reloads and runtime bundle changes share. |
+| `ctx.profileRuntime` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`host-plugin-inventory`](../packages/host/plugin-inventory), [`host-plugin-manager`](../packages/host/plugin-manager) | - | Provided by the profile launcher before entries mount: the composed bundle layers, each row's inserting layer, the rows user patch files disable, and the one recomposition path user patch reloads and runtime bundle changes share. |
 | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. |
 | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and the Session controller serves baselines and pushes changed values. |
 | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. |

+ 1 - 1
docs/capability-seams.zh.md

@@ -524,7 +524,7 @@ flowchart LR
 | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | 折叠已记录的计划/模式状态,在轮次边界刷新用户选择,渲染由部署方拥有的指导信息,注册 /plan,并在状态转换期间保持计划退出 schema 稳定。 |
 | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | 在受信任根目录与用户创作根目录上发现 preset 目录,并在创建期把一份 preset cordis.yml 挂载到 agent 作用域之下,拒绝始终未激活或向根服务 realm 发布服务的行。 |
 | `ctx.pluginManager` | `core` | [`host-plugin-manager`](../packages/host/plugin-manager) | - | - | - | 经 pnpm 与 profile runtime 安装、启用、停用、重试与移除组合包,编辑 profile 或某个 preset 用户层里的行,并把 manifest、探针与树的事实折叠成每个包一份视图供 plugins Remote 使用。 |
-| `ctx.profileRuntime` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`host-plugin-inventory`](../packages/host/plugin-inventory), [`host-plugin-manager`](../packages/host/plugin-manager) | - | 由 profile launcher 在启动后提供:已组合的组合包层及其 trust 与 stage、每一行的插入层、用户 patch 文件停用的行,以及用户 patch 重载与运行时组合包变更共用的那一条重新组合路径。 |
+| `ctx.profileRuntime` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`host-plugin-inventory`](../packages/host/plugin-inventory), [`host-plugin-manager`](../packages/host/plugin-manager) | - | 由 profile launcher 在条目挂载前提供:已组合的组合包层、每一行的插入层、用户 patch 文件停用的行,以及用户 patch 重载与运行时组合包变更共用的那一条重新组合路径。 |
 | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | 插件注册直接面向人的命令,而不会把调用发送给模型。 |
 | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | 各领域注册由状态驱动的折叠单元;主动驱动过程维护每个会话的水位状态,Session controller 提供 baseline 并推送发生变化的值。 |
 | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),并提供冷读取阶梯:缓存行加持久化尾部回放,因此列表读取永远不需要加载完整日志。 |

+ 2 - 2
docs/event-producer-consumer.md

@@ -47,8 +47,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
-| `plugins/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:248`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugins/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:254`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugins/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:240`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugins/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:246`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
 | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
 | `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |

+ 2 - 2
docs/user/develop/basic/publish.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/user/develop/basic/publish.md
-publish.md: 82a13cb9ae14bfcc121dc47d7c31590b8d8f0b5a
-publish.zh.md: 196d55746af68ec65eabcd64f76323a4c8baf367
+publish.md: 5b32e40fce38905f77efe2bc0c4b7ffd67fdb9d7
+publish.zh.md: b03322b805c9310d28206d1ab1b4687cd8cd93b3

+ 1 - 1
docs/user/develop/basic/publish.md

@@ -111,7 +111,7 @@ dsh --profile demo
 
 `dsh plugin --profile demo remove dsh-hello-plugin` removes both the dependency and the layer.
 
-An installed bundle mounts as an external layer: the dump shows its rows inside one group named `bundle/dsh-hello-plugin`, and each row keeps the id your patch declares, so the row above is `hello` in the mounted tree and a user patch that targets it names that id. Row ids are shared across every layer: if another layer already declares one of yours, or your patch declares one twice, the whole bundle is left out and the reason is printed at boot and shown in the plugin list. A row of yours that fails to start is isolated and reported in the plugin list instead of stopping `dsh`; if your bundle provides a service the built-in rows inject, declare `dsh.bundle.stage: boot` so it mounts like a built-in one and fails loud.
+A bundle mounts its rows under the parents declared in its patch and keeps their ids: the row above remains `hello`. Layers claim ids in profile order; a bundle that repeats an id or declares an earlier layer’s id is omitted whole and reported at startup and in the plugin list. A failed row outside the [required startup ids](../../../architecture.md) produces a warning while successful siblings remain active. A required consumer that cannot activate, including because its provider failed, still rejects startup.
 
 ## The loading order
 

+ 1 - 1
docs/user/develop/basic/publish.zh.md

@@ -111,7 +111,7 @@ dsh --profile demo
 
 `dsh plugin --profile demo remove dsh-hello-plugin` 会同时移除依赖和对应的层。
 
-已安装的组合包作为外部层挂载:dump 会把它的行显示在一个名为 `bundle/dsh-hello-plugin` 的组里,每一行保持你的 patch 所声明的 id,因此上面那一行在挂载后的树里仍是 `hello`,针对它的用户 patch 就写这个 id。行 id 在所有层之间共用:如果别的层已经声明了你的某个 id,或者你的 patch 把一个 id 声明了两次,整个组合包会被排除,原因在启动时打印并显示在插件列表里。你的某一行启动失败时会被隔离并在插件列表里报告,而不是让 `dsh` 停下;如果你的组合包提供内置行所注入的服务,请声明 `dsh.bundle.stage: boot`,让它像内置行一样挂载并明确失败。
+组合包按 patch 声明的父节点挂载行并保留 id:上面的行仍为 `hello`。各层按 profile 顺序占用 id;组合包重复声明 id 或声明前面层已占用的 id 时会被整层排除,并在启动时和插件列表中报告。不在[启动必需 id](../../../architecture.zh.md) 中的行失败只产生警告,成功的兄弟条目继续运行。必需消费者无法激活时仍拒绝启动,包括其提供方失败导致无法激活的情况。
 
 ## 加载顺序
 

+ 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: 29265155fded79eee93a5ec8da7f7c198f551049
-README.zh.md: 2a9c3994ef64c423fdefbda2002f0e1eecb15f70
+README.md: 22a296fb2c743e107c9c359a66a9c6f74340ad74
+README.zh.md: 62692813cc4c80f6b04e5a8280cd86110f268e73

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

@@ -40,7 +40,7 @@ installFailLoud('dsh')
 const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT))
 ```
 
-Startup policy follows the bundle that introduced each row. Only `external` / `runtime` rows may fail with a warning while their successful siblings remain active. Built-in, boot-staged, and unowned rows are required: an enabled failure disposes the app and rejects startup. Disabled rows are ignored; the bootstrap Include is always required. Direct calls without profile provenance are strict.
+Startup audits use the [global required entry ids](../../../.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md), plus the bootstrap Include by identity. Present, enabled required entries must activate; absent and disabled ids do not affect startup. All other entry failures produce warnings and preserve successful siblings, including built-in tools, user patch rows, and direct `boot()` calls without a profile runtime.
 
 <a id="profiles"></a>
 ### Profiles
@@ -58,7 +58,7 @@ Profiles with `patchReload: live` watch both user patch files and apply the [rel
 
 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.
 
-Installed dependencies default to `external`; template bundles and packages in `dsh.profile.firstParty` are `builtin`. Effective stage is `dsh.profile.stages[package]`, then the package’s `dsh.bundle.stage`, then `runtime`. Stage selects startup failure policy, not a later execution phase. Bundle patches retain their declared ids, parents, and ordering. Built-in and boot-staged layers claim ids first; an optional bundle with a duplicate id is omitted whole and reported, while a conflicting user insert is omitted per row. `dependencies` records installation; `dsh.profile.bundles` selects enabled layers, including all their inserts and overrides.
+Bundle patches retain their declared ids, parents, and ordering. Layers claim row ids in manifest order; a bundle with a repeated or already claimed id is omitted whole and reported, while a conflicting user insert is omitted per row. `dependencies` records installation; `dsh.profile.bundles` selects enabled layers, including all their inserts and overrides. Package metadata does not select startup strictness.
 
 The launcher provides `ctx.profileRuntime` before any configuration entry mounts. The runtime requires Loader and supports calls through both root and plugin contexts. It owns row provenance, accepted composition, conflicts, and user-disabled rows. Watchers and management operations share its serial recomposition queue. Recomposition waits for current entries and removed fibers, then publishes accepted options and reports per-entry issues; a failed update can leave a fiber running its previous valid config. `installFailLoud` remains installed until shutdown for process-level unhandled rejections. Observers can await `whenIdle()` before publishing a refreshed view of composition ownership.
 
@@ -135,7 +135,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.
 - **Two Loader builtins.** `mountRootInclude` registers `cordis:include` and `cordis:group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, and an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name. Both load through the ambient module pipeline rather than the included tree's own specifier resolution.
-- **Consumer-owned strictness.** Ordinary Loader groups keep successful siblings. App-boot audits provenance after startup settlement; agent presets own and clean up generations that require every configured entry to activate. Entry failures are read from the Loader and Fiber, with duplicate rejection notifications coalesced through one process checkpoint.
+- **Consumer-owned strictness.** Ordinary Loader groups keep successful siblings. App-boot audits required entry ids after startup settlement; agent presets own and clean up generations that require every configured entry to activate. Entry failures are read from the Loader and Fiber, with duplicate rejection notifications coalesced through one process checkpoint.
 - **Profile module fallback.** Bare plugin specifiers resolve through the Loader from the config directory. Plain Node maintains one symlink per package in the installation dependency closure. A packaged executable instead reads each installed export map with Node ESM conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Missing exports stay unavailable, malformed maps fail startup, and a cross-process writer lock replaces stale entries without exposing partial proxies. A selected external bundle absent from the installation closure receives a profile-local `.dsh-module-fallback` link; existing pnpm entries win, projected links are excluded from later closure discovery, and cleanup removes only dsh-owned links.
 - **Update completion.** Live reload waits for Loader work before inspecting entry issues. Profile recomposition also waits for removed fibers, whose cleanup is absent from the current Loader tree. `Fiber.update()` and `Entry.update()` alone do not establish restart success.
 - **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`. Plugin diagnostics include original stacks, nested causes, and aggregate member failures. Cyclic causes terminate diagnostic traversal without replacing the original cause.
@@ -149,7 +149,7 @@ The exports each own one stage of the boot: config resolution and snapshot repla
 | File | Role |
 |---|---|
 | [`src/index.ts`](src/index.ts) | Boot, environment layers, fail-loud guard, startup audit, patch parsing and watching, config dump |
-| [`src/profile.ts`](src/profile.ts) | Profile discovery, initialization, bundle resolution with trust and stage, module fallback |
+| [`src/profile.ts`](src/profile.ts) | Profile discovery, initialization, bundle resolution, module fallback |
 | [`src/external-bundles.ts`](src/external-bundles.ts) | Bundle ownership analysis and installed/enabled manifest lists |
 | [`src/compose-stack.ts`](src/compose-stack.ts) | Row-id ownership across the stack: `claimLayerIds`, `composeProfileStack`, conflict records |
 | [`src/entry-issues.ts`](src/entry-issues.ts) | Current entry failures, unresolved services, and diagnostic formatting |

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

@@ -40,7 +40,7 @@ installFailLoud('dsh')
 const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT))
 ```
 
-启动策略由引入该行的组合包决定。只有 `external` / `runtime` 行可以在失败后输出警告,同时保留成功的其他行。内置、boot 阶段和无归属行均为必需行:启用但失败时会清理应用并拒绝启动。已禁用的行被忽略;启动 Include 始终为必需。未提供 profile 来源信息的直接调用采用严格策略。
+启动审查使用[全局必需条目 id](../../../.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md),另按条目身份将启动 Include 视为必需。已存在且启用的必需条目必须激活;缺失和禁用的 id 不影响启动。其余条目失败只产生警告并保留成功的兄弟条目,包括内置工具、用户 patch 行,以及没有 profile runtime 的直接 `boot()` 调用。
 
 <a id="profiles"></a>
 ### Profile
@@ -58,7 +58,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目及其嵌套分组中的绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。
 
-已安装依赖默认为 `external`;模板组合包和 `dsh.profile.firstParty` 中的包为 `builtin`。有效 stage 依次取 `dsh.profile.stages[package]`、包的 `dsh.bundle.stage`,最后默认为 `runtime`。stage 决定启动失败策略,不表示稍后执行。组合包 patch 保留声明的 id、父组和顺序。内置与 boot 阶段层优先占有 id;可选组合包有重复 id 时整层被排除并报告,冲突的用户插入则逐行排除。`dependencies` 记录安装;`dsh.profile.bundles` 选择启用的层,包括它们的全部插入和覆盖。
+组合包 patch 保留声明的 id、父组和顺序。各层按 manifest 顺序占有行 id;组合包重复声明或使用已占用的 id 时整层被排除并报告,冲突的用户插入则逐行排除。`dependencies` 记录安装;`dsh.profile.bundles` 选择启用的层,包括它们的全部插入和覆盖。包元数据不决定启动严格程度。
 
 launcher 在任何配置行挂载前提供 `ctx.profileRuntime`。该运行时要求注入 Loader,支持从根上下文和插件上下文取得的句柄调用。它拥有行来源、已接受的组合、冲突和用户禁用行信息。文件监听与管理操作共用它的串行重组队列。重组等待当前条目和已移除 fiber 完成后,发布已接受的选项并报告逐行问题;更新失败时,fiber 可能仍使用先前的有效配置运行。`installFailLoud` 保持到应用关闭,处理进程级未处理 rejection。 观测方可等待 `whenIdle()` 后,再发布包含组合归属的刷新视图。
 
@@ -135,7 +135,7 @@ Loader 结算后,app-boot 将 optional 失败报告为警告;若已启用的
 
 - **与渠道无关的库。** 此包不包含 loader 钩子,也不提供开发模式接口;[`dsh` 应用](../../../apps/cli/README.zh.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper,构建后的消费方则使用普通 Node 包解析。
 - **两个 Loader builtin。** `mountRootInclude` 把 `cordis:include` 与 `cordis:group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,而位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。
-- **消费方决定严格程度。** 普通 Loader 组保留成功的其他行。app-boot 在启动完成后按来源审计;agent preset 拥有并清理要求全部配置行激活的代际。行失败从 Loader 与 Fiber 读取,重复的 rejection 通知在一个进程检查点内合并。
+- **消费方决定严格程度。** 普通 Loader 组保留成功的其他行。app-boot 在启动完成后按必需条目 id 审计;agent preset 拥有并清理要求全部配置行激活的代际。行失败从 Loader 与 Fiber 读取,重复的 rejection 通知在一个进程检查点内合并。
 - **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部组合包若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。
 - **更新完成。** 实时重载等待 Loader 工作后检查逐行问题。profile 重组还等待已移除 fiber 的清理,这些工作已不在当前 Loader 树中。单独完成 `Fiber.update()` 和 `Entry.update()` 不代表重启成功。
 - **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`。插件诊断包含原始堆栈、嵌套原因和聚合错误中的各项失败。原因链出现循环时,诊断遍历会终止,不会替换原始原因。
@@ -149,7 +149,7 @@ Loader 结算后,app-boot 将 optional 失败报告为警告;若已启用的
 | 文件 | 职责 |
 |---|---|
 | [`src/index.ts`](src/index.ts) | 启动、环境层、fail-loud 处理、启动审计、patch 解析与监听、配置导出 |
-| [`src/profile.ts`](src/profile.ts) | profile 发现、初始化、带 trust 与 stage 的组合包解析、模块后备机制 |
+| [`src/profile.ts`](src/profile.ts) | profile 发现、初始化、组合包解析、模块后备机制 |
 | [`src/external-bundles.ts`](src/external-bundles.ts) | 组合包归属分析及安装、启用 manifest 列表 |
 | [`src/compose-stack.ts`](src/compose-stack.ts) | 整叠层的行 id 归属:`claimLayerIds`、`composeProfileStack`、冲突记录 |
 | [`src/entry-issues.ts`](src/entry-issues.ts) | 当前条目失败、未满足服务和诊断格式化 |

+ 16 - 61
packages/boot/app-boot/src/compose-stack.ts

@@ -1,22 +1,14 @@
 /**
- * The profile patch stack composed with tree-wide row-id ownership. Entry ids
- * are unique per Loader tree, and the vendored group `create()` re-parents an
- * existing id instead of rejecting it, so a shared id namespace needs its
- * check before anything mounts: built-in and boot-staged layers claim their
- * ids first and a duplicate among or inside them fails loud; an external
- * bundle whose id is already claimed, or which declares one of its own ids
- * twice, is skipped whole and recorded as a conflict; a user layer's insert
- * of a claimed id drops that row and records it. One composition yields the
- * patches, the owner of every id, and the conflicts, and each optional
- * layer is analyzed once. Boot, live recomposition, and the config dump all
- * compose through here, so they agree.
+ * Profile composition with row-id ownership in manifest order. A bundle with
+ * duplicate ids is omitted whole and reported; conflicting user inserts are
+ * omitted per row. Startup failure policy belongs to the application audit.
  * @module @deepseek-ai/dsh-app-boot/compose-stack
  */
 
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import { analyzeBundleLayer, isOptionalRuntimeLayer, type AnalyzedBundleLayer } from './external-bundles.ts'
-import { visitIdentifiedRows, visitPatchRows, visitRowTree } from './patch-rows.ts'
+import { analyzeBundleLayer, type AnalyzedBundleLayer } from './external-bundles.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. */
@@ -35,7 +27,7 @@ export interface RowConflict {
   readonly moduleName: string
   /** The layer that lost: a bundle's package name or a user layer's label. */
   readonly layer: string
-  /** The external bundle that lost, when the loser is one; its whole layer is left out. */
+  /** The bundle that lost, when the loser is one; its whole layer is left out. */
   readonly packageName?: string
   /**
    * The layer that already declares the id: a bundle's package name or a user
@@ -66,13 +58,13 @@ export interface ComposedStack {
   readonly userDisabledRowIds: ReadonlySet<string>
 }
 
-/** Row-id ownership across the bundle layers: who owns each id, which external bundles lost, and how the rest mount. */
+/** Row-id ownership across the bundle layers: who owns each id, which bundles lost, and how the rest mount. */
 export interface LayerOwnership {
   /** The layer that owns each id a bundle layer introduces. */
   readonly owners: Map<string, ProfileLayer>
-  /** The conflicts of each external bundle left out, by package name. */
+  /** The conflicts of each bundle left out, by package name. */
   readonly skipped: Map<string, RowConflict[]>
-  /** The analysis of each optional layer that owns its ids, by package name; rendered once and mounted as is. */
+  /** The analysis of each bundle layer that owns its ids, by package name; rendered once and mounted as is. */
   readonly composed: Map<string, AnalyzedBundleLayer>
 }
 
@@ -95,46 +87,17 @@ function rowIds(row: EntryOptions): string[] {
 }
 
 /**
- * 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 under the
- * group the same layer declared it in is that layer keeping its own child;
- * the same id twice in one config list, or set under another group, is
- * declared twice. 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.
+ * Claim bundle rows in manifest order. A layer that repeats an id or declares
+ * an earlier layer's id is omitted whole. Restating a child in the same group
+ * preserves its ownership; moving it to another group is a duplicate.
  * @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.
+ * @returns row owners, rejected-bundle conflicts, and each accepted layer's analysis.
  */
 export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
   const owners = new Map<string, ProfileLayer>()
-  const declaredUnder = new Map<string, string | undefined>()
-  for (const layer of layers) {
-    if (isOptionalRuntimeLayer(layer)) continue
-    visitIdentifiedRows(layer.patches, ({ id, source, place, listed }) => {
-      const owner = owners.get(id)
-      if (owner === layer) {
-        // Restating a row under the group that already holds it is the layer
-        // keeping its child; inserting it again, listing it twice in one
-        // config, or setting it under another group is declaring it twice.
-        if (source === 'insert' || listed.has(id) || declaredUnder.get(id) !== place.target) {
-          throw new Error(`row ${JSON.stringify(id)} is declared twice by ${layer.packageName}`)
-        }
-      } else if (owner !== undefined) {
-        throw new Error(`row ${JSON.stringify(id)} is declared by both ${owner.packageName} and ${layer.packageName}`)
-      } else {
-        owners.set(id, layer)
-        declaredUnder.set(id, place.target)
-      }
-    })
-  }
   const skipped = new Map<string, RowConflict[]>()
   const composed = new Map<string, AnalyzedBundleLayer>()
   for (const layer of layers) {
-    if (!isOptionalRuntimeLayer(layer)) continue
     const { packageName } = layer
     const composition = analyzeBundleLayer(layer)
     const conflicts: RowConflict[] = composition.duplicates.map(({ rowId, moduleName }) => (
@@ -161,14 +124,12 @@ export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
  * ids, in manifest order, then the user layers with any insert of an already
  * owned id dropped. Bundle patches are copied before assigning anonymous ids;
  * user patches remain shared input, so callers mount a detached execution copy.
- * @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, 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(
-  binName: string, layers: readonly ProfileLayer[], userLayers: readonly StackUserLayer[],
+  layers: readonly ProfileLayer[], userLayers: readonly StackUserLayer[],
 ): ComposedStack {
   // Only anonymous bundle rows need an assigned id. The execution copy keeps
   // provenance available after loading without modifying author-owned patches.
@@ -178,12 +139,7 @@ export function composeProfileStack(
     visitPatchRows(patches, (row) => { row.id ||= `anonymous/${layer.packageName}/${String(anonymous++)}` })
     return { ...layer, patches }
   })
-  let ownership: LayerOwnership
-  try {
-    ownership = claimLayerIds(layers)
-  } catch (error) {
-    throw new Error(`${binName}: ${(error as Error).message}`)
-  }
+  const ownership = claimLayerIds(layers)
   const composedLayers: StackUserLayer[] = []
   const conflicts: RowConflict[] = []
   const skippedBundles: string[] = []
@@ -194,8 +150,7 @@ export function composeProfileStack(
       skippedBundles.push(layer.packageName)
       continue
     }
-    const composition = ownership.composed.get(layer.packageName)
-    composedLayers.push({ label: layer.packageName, patches: composition === undefined ? layer.patches : composition.patches })
+    composedLayers.push({ label: layer.packageName, patches: layer.patches })
   }
   const claimed = new Map<string, string>()
   for (const [id, layer] of ownership.owners) claimed.set(id, layer.packageName)

+ 0 - 9
packages/boot/app-boot/src/external-bundles.ts

@@ -22,15 +22,6 @@ export interface AnalyzedBundleLayer {
   readonly overrides: string[]
 }
 
-/**
- * Whether an enabled bundle may fail without rejecting application startup.
- * @param layer - the resolved provenance and stage.
- * @returns true only for external runtime layers.
- */
-export function isOptionalRuntimeLayer(layer: ProfileLayer): boolean {
-  return layer.trust === 'external' && layer.stage === 'runtime'
-}
-
 /**
  * Inspect the rows a layer introduces without changing their ids or parents.
  * @param layer - the bundle patch list.

+ 25 - 17
packages/boot/app-boot/src/index.ts

@@ -31,7 +31,6 @@ declare module '@deepseek-ai/cordis' {
 }
 
 export {
-  layerTrust,
   composeEntries,
   DEFAULT_PROFILE_BUNDLES,
   DEFAULT_PROFILE_PATCH_RELOAD,
@@ -47,7 +46,6 @@ export {
   resolveProfileDir,
   resolveProfileLayer,
   writeProfileManifest,
-  type BundleTrust,
   type Profile,
   type ProfileLayer,
   type ProfileManifest,
@@ -658,11 +656,23 @@ async function inactiveEntries(ctx: Context): Promise<InactiveEntry[]> {
   }))
 }
 
-/** The policy follows row provenance, including the owner of nested Includes. */
-function isOptionalEntry(ctx: Context, entry: Entry): boolean {
-  const origin = ctx.get('profileRuntime')?.originOfEntry(entry)
-  return origin?.trust === 'external' && origin.stage === 'runtime'
-}
+/**
+ * Entry ids whose presence defines a usable DSH application.
+ *
+ * The list is global rather than profile metadata. Missing or disabled ids do
+ * not affect startup; an enabled listed entry must activate. The list covers
+ * shared Agent execution, application endpoints, and Web bootstrap/transport.
+ */
+const requiredStartupEntryIds = new Set<string>([
+  'agent-loop',
+  'webserver',
+  'modules',
+  'connection',
+  'headless-runner',
+  'acp',
+  'sdk-jsonrpc-server',
+])
+
 
 /** Render an inactive-entry diagnostic with a count and severity label. */
 function activationDiagnostic(
@@ -678,9 +688,9 @@ function activationDiagnostic(
 /**
  * Apply DSH startup policy to a settled Loader tree.
  *
- * Inactive built-in, boot-staged, and unowned entries reject startup. External runtime
- * inactive entries produce one warning and leave successful siblings running.
- * Disabled entries are ignored. Provenance is read from the prepared profile runtime.
+ * An enabled entry in the global required-id list must activate; absent and disabled
+ * ids do not affect startup. Other inactive entries produce one warning and leave
+ * successful siblings running, with or without a profile runtime.
  * A throwing disabled expression is an entry failure, not a disabled entry.
  * The bootstrap Include must activate so unreadable or invalid root config is fatal.
  * @param ctx - the settled context whose Loader entries to audit.
@@ -699,7 +709,7 @@ export async function auditStartupEntries(
   const optional: InactiveEntry[] = []
   for (const failure of failures) {
     const target = failure.entry === bootstrapIncludes.get(ctx)
-      || !isOptionalEntry(ctx, failure.entry) ? required : optional
+      || requiredStartupEntryIds.has(failure.entry.options.id) ? required : optional
     target.push(failure)
   }
   if (optional.length > 0) warn(activationDiagnostic(binName, 'warning', optional))
@@ -711,9 +721,8 @@ export async function auditStartupEntries(
 /**
  * Nested plugin fibers (a `ctx.inject()` continuation inside a plugin) fail
  * without touching their entry's root fiber, so the activation audit above
- * cannot see them. Report every failed nested fiber under a built-in entry
- * through `warn`, one line per fiber. Advisory in this release; it becomes
- * part of the fatal audit once shipped compositions are known clean.
+ * cannot see them. Report every failed nested fiber through `warn`, one line
+ * per fiber, without rejecting startup.
  * @param ctx - the settled context whose runtimes to inspect.
  * @param binName - the diagnostic prefix on each warning.
  * @param warn - sink for the warning lines; defaults to the context logger.
@@ -730,8 +739,7 @@ export function warnNestedFiberFailures(
       // The Loader stamps every fiber created inside an entry's context with
       // that entry; the entry's own root fiber is the one the entry holds.
       const owner = fiber.entry
-      if (fiber.state !== FIBER_FAILED || owner === undefined || owner.fiber === fiber) continue
-      if (isOptionalEntry(ctx, owner)) continue
+      if (fiber.state !== FIBER_FAILED || owner === undefined || owner.fiber?.uid === fiber.uid) continue
       count += 1
       warn(`${binName}: nested fiber under ${owner.options.name} failed (${runtime.name}); the entry itself stays active`)
     }
@@ -749,7 +757,7 @@ export function warnNestedFiberFailures(
  * embeds Include while leaving Loader external, so the built include tree and
  * host share one Loader peer. Loader settlement drains entry work without
  * rejecting the whole tree. The final {@link auditStartupEntries} call rejects
- * failures in required profile rows and warns about other failed, missing,
+ * failures in the global required list and warns about other failed, missing,
  * and pending entries while successful siblings remain active. Later unhandled
  * rejections remain covered by {@link installFailLoud}. Built bins need the Loader's native
  * helper for bare plugin specifiers; relative specifiers do not.

+ 2 - 8
packages/boot/app-boot/src/profile-runtime.ts

@@ -3,9 +3,9 @@
 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 { BundleStage, ProfilePatchReload } from '@deepseek-ai/dsh-package-manifest'
+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'
+import type { Profile, ProfileLayer } from './profile.ts'
 import { inspectEntryIssues, type EntryIssue } from './entry-issues.ts'
 
 declare module '@deepseek-ai/cordis' {
@@ -17,10 +17,6 @@ declare module '@deepseek-ai/cordis' {
 
 /** Where one mounted row came from. */
 export interface RowOrigin {
-  /** Who supplied the layer that inserted the row. */
-  readonly trust: BundleTrust
-  /** Effective startup-failure policy of the supplying bundle. */
-  readonly stage: BundleStage
   /** The bundle package that inserted the row. */
   readonly packageName: string
   /** The package's version, when its manifest declares one. */
@@ -111,8 +107,6 @@ export class ProfileRuntime extends Service {
     const layer = this.committed.stack.owners.get(rowId)
     if (layer === undefined) return undefined
     return {
-      trust: layer.trust,
-      stage: layer.stage,
       packageName: layer.packageName,
       ...layer.version === undefined ? {} : { version: layer.version },
     }

+ 5 - 49
packages/boot/app-boot/src/profile.ts

@@ -34,7 +34,7 @@ import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
-import type { BundleStage, DshPackageManifest, ProfilePatchReload } from '@deepseek-ai/dsh-package-manifest'
+import type { DshPackageManifest, ProfilePatchReload } from '@deepseek-ai/dsh-package-manifest'
 import { resolve as resolvePackage, type Package as ResolvePackageManifest } from 'resolve.exports'
 import { loadOverlayPatches } from './index.ts'
 
@@ -47,13 +47,6 @@ export const PROFILE_PATCH_FILENAME = 'cordis.patch.yml'
 /** Profile-private package links projected into its pnpm-managed node_modules. */
 const PROFILE_MODULE_FALLBACK_DIR = '.dsh-module-fallback'
 
-/**
- * Who supplied a bundle layer. `builtin` layers come with the installation
- * (template bundles) or are declared first-party by the profile; `external`
- * layers are pnpm-managed dependencies the user installed.
- */
-export type BundleTrust = 'builtin' | 'external'
-
 /** Installation-owned defaults used when a shipped profile is first opened. */
 export interface ProfileTemplate {
   /** Ordered bundle layer list. */
@@ -75,10 +68,6 @@ export interface ProfileLayer {
   packageDir: string
   /** Absolute path of the bundle's patch file. */
   patchPath: string
-  /** Who supplied the layer; decides row ownership priority and startup failure policy. */
-  trust: BundleTrust
-  /** Effective mount stage: the profile's override, else the bundle's declaration, else `runtime`. */
-  stage: BundleStage
   /** The parsed patch list. */
   patches: PatchOptions[]
 }
@@ -688,33 +677,6 @@ export function writeProfileManifest(dir: string, manifest: ProfileManifest): vo
   writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n')
 }
 
-/**
- * Who supplied one bundle of a profile. Provenance, not the package name,
- * decides: a template bundle is never a dependency, and everything pnpm
- * added is out-of-tree — a fork that kept a first-party name still lands in
- * `dependencies` — unless the profile lists it under `dsh.profile.firstParty`.
- * @param manifest - the profile manifest.
- * @param packageName - the bundle's package name.
- * @returns `external` for an installed third-party bundle, else `builtin`.
- */
-export function layerTrust(manifest: ProfileManifest, packageName: string): BundleTrust {
-  const installed = packageName in (manifest.dependencies ?? {})
-  const firstParty = manifest.dsh?.profile?.firstParty ?? []
-  return installed && !firstParty.includes(packageName) ? 'external' : 'builtin'
-}
-
-/**
- * Validate one bundle stage value read from a manifest; an unknown value is a
- * misconfiguration and fails at load.
- */
-function readBundleStage(binName: string, packageName: string, value: unknown): BundleStage {
-  if (value === undefined) return 'runtime'
-  if (value === 'boot' || value === 'runtime') return value
-  throw new Error(
-    `${binName}: bundle ${JSON.stringify(packageName)} declares stage ${JSON.stringify(value)}; expected "boot" or "runtime"`,
-  )
-}
-
 /** Return whether two bundle lists have the same values in the same order. */
 function sameBundles(left: readonly string[], right: readonly string[]): boolean {
   return left.length === right.length && left.every((value, index) => value === right[index])
@@ -798,19 +760,16 @@ export function resolveBundleDir(
 
 /**
  * Resolve one bundle into the layer it contributes: its directory through
- * the two resolution anchors, its patch list, its trust from the profile
- * manifest, and its stage from the profile's `dsh.profile.stages` override or
- * its own `dsh.bundle.stage`.
+ * the two resolution anchors and its declared patch list.
  * @param binName - the diagnostic prefix on thrown errors.
- * @param manifest - the profile manifest, for trust and stage overrides.
  * @param packageName - the bundle package.
  * @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor).
  * @param dir - the profile directory (second resolution anchor).
  * @returns the resolved layer.
- * @throws when the package cannot be resolved, declares no `dsh.bundle`, or names an unknown stage.
+ * @throws when the package cannot be resolved, or declares no `dsh.bundle`.
  */
 export function resolveProfileLayer(
-  binName: string, manifest: ProfileManifest, packageName: string, installAnchor: string, dir: string,
+  binName: string, packageName: string, installAnchor: string, dir: string,
 ): ProfileLayer {
   const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir)
   const bundleManifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as ProfileManifest
@@ -819,14 +778,11 @@ export function resolveProfileLayer(
     throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)
   }
   const patchPath = join(packageDir, declared)
-  const stages = manifest.dsh?.profile?.stages ?? {}
   return {
     packageName,
     version: bundleManifest.version,
     packageDir,
     patchPath,
-    trust: layerTrust(manifest, packageName),
-    stage: readBundleStage(binName, packageName, stages[packageName] ?? bundleManifest.dsh?.bundle?.stage),
     patches: loadOverlayPatches(binName, patchPath),
   }
 }
@@ -856,7 +812,7 @@ export function loadProfileDirectory(
     )
   }
   const patchReload = rawPatchReload ?? DEFAULT_PROFILE_PATCH_RELOAD
-  const layers = bundles.map(packageName => resolveProfileLayer(binName, manifest, packageName, installAnchor, dir))
+  const layers = bundles.map(packageName => resolveProfileLayer(binName, packageName, installAnchor, dir))
   const patchPath = join(dir, PROFILE_PATCH_FILENAME)
   const patches = options.userLayer !== false && existsSync(patchPath)
     ? loadOverlayPatches(binName, patchPath)

+ 3 - 7
packages/boot/app-boot/tests/app-boot.spec.ts

@@ -8,7 +8,7 @@ import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
 import {
   addHarnessSourceSection, auditStartupEntries, boot,
   FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION,
-  installFailLoud, ProfileRuntime, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
+  installFailLoud, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
 } from '../src/index.ts'
 
 const NAME = 'dsh-test-bin'
@@ -429,7 +429,6 @@ describe('installFailLoud', () => {
       return {} as NodeJS.Immediate
     }))
     const audit = auditStartupEntries({
-      get: () => ({ originOfEntry: () => ({ trust: 'external', stage: 'runtime' }) }),
       loader: {
         entries: () => ['broken-a', 'broken-b'].map(name => ({
           options: { id: name, name },
@@ -517,7 +516,7 @@ describe('installFailLoud', () => {
 })
 
 describe('auditStartupEntries', () => {
-  const requiredIds = ['required-a', 'webserver', 'headless-runner']
+  const requiredIds = ['agent-loop', 'webserver', 'modules', 'connection', 'headless-runner', 'acp', 'sdk-jsonrpc-server']
 
   interface FakeEntry {
     fiber?: {
@@ -531,7 +530,6 @@ describe('auditStartupEntries', () => {
   }
 
   const ctxWith = (entries: FakeEntry[]): Context => ({
-    get: () => ({ originOfEntry: (entry: FakeEntry) => requiredIds.includes(entry.options.id) ? undefined : { trust: 'external', stage: 'runtime' } }),
     loader: { entries: () => entries.values() },
   }) as unknown as Context
 
@@ -924,9 +922,7 @@ describe('boot', () => {
     const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
     let ctx: Context | undefined
     try {
-      ctx = await boot(NAME, configPath, [], (ctx) => {
-        ctx.provide('profileRuntime', { originOfEntry: () => ({ trust: 'external', stage: 'runtime' }) } as unknown as ProfileRuntime)
-      })
+      ctx = await boot(NAME, configPath)
       expect(ctx.get('goodStarted')).toBe(true)
       const entries = [...ctx.loader.entries()]
       expect(entries.find(entry => entry.options.id === 'good')?.fiber?.state).toBe(2)

+ 42 - 40
packages/boot/app-boot/tests/compose-stack.spec.ts

@@ -11,23 +11,21 @@ import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import { claimLayerIds, composeProfileStack, formatRowConflict, type ProfileLayer } from '../src/index.ts'
 
-const NAME = 'dsh-test-bin'
-
 function layer(
-  packageName: string, trust: ProfileLayer['trust'], patches: PatchOptions[], stage: ProfileLayer['stage'] = 'runtime',
+  packageName: string, patches: PatchOptions[],
 ): ProfileLayer {
-  return { packageName, version: '1.0.0', packageDir: '/nowhere', patchPath: '/nowhere/cordis.patch.yml', trust, stage, patches }
+  return { packageName, version: '1.0.0', packageDir: '/nowhere', patchPath: '/nowhere/cordis.patch.yml', patches }
 }
 
-const base = layer('@deepseek-ai/dsh-base', 'builtin', [{ insert: [
+const base = layer('@deepseek-ai/dsh-base', [{ insert: [
   { id: 'settings', name: 'settings' },
   { id: 'tools', name: 'cordis:group', group: true, config: [{ id: 'tool-bash', name: 'bash' }] },
 ] }])
 
 describe('claimLayerIds', () => {
-  it('lets built-in layers own their ids, including group children, before any external layer', () => {
-    const ext = layer('ext', 'external', [{ insert: [{ id: 'tool-bash', name: 'ext' }] }])
-    const { owners, skipped } = claimLayerIds([ext, base])
+  it('preserves earlier bundle ids, including group children', () => {
+    const ext = layer('ext', [{ insert: [{ id: 'tool-bash', name: 'ext' }] }])
+    const { owners, skipped } = claimLayerIds([base, ext])
     expect(owners.get('tool-bash')?.packageName).toBe('@deepseek-ai/dsh-base')
     expect(owners.get('tools')?.packageName).toBe('@deepseek-ai/dsh-base')
     expect(skipped.get('ext')).toEqual([
@@ -38,15 +36,23 @@ describe('claimLayerIds', () => {
     ])
   })
 
-  it('throws when two built-in or boot-staged layers declare one id, or one declares it twice', () => {
-    const twin = layer('twin', 'external', [{ insert: [{ id: 'settings', name: 'twin' }] }], 'boot')
-    expect(() => claimLayerIds([base, twin])).toThrow(/row "settings" is declared by both @deepseek-ai\/dsh-base and twin/)
-    const stutter = layer('stutter', 'builtin', [{ insert: [{ id: 'x', name: 'a' }] }, { insert: [{ id: 'x', name: 'b' }] }])
-    expect(() => claimLayerIds([stutter])).toThrow(/row "x" is declared twice by stutter/)
+  it('uses declaration order even when a package has an official name', () => {
+    const earlier = layer('community', [{ insert: [{ id: 'settings', name: 'community/settings' }] }])
+    const result = claimLayerIds([earlier, base])
+    expect(result.owners.get('settings')?.packageName).toBe('community')
+    expect(result.skipped.get('@deepseek-ai/dsh-base')?.map(conflict => conflict.rowId)).toEqual(['settings'])
+    expect(result.owners.has('tools')).toBe(false)
+  })
+
+  it('omits a bundle that conflicts with an earlier layer or repeats its own id', () => {
+    const twin = layer('twin', [{ insert: [{ id: 'settings', name: 'twin' }] }])
+    expect(claimLayerIds([base, twin]).skipped.get('twin')?.map(conflict => conflict.rowId)).toEqual(['settings'])
+    const stutter = layer('stutter', [{ insert: [{ id: 'x', name: 'a' }] }, { insert: [{ id: 'x', name: 'b' }] }])
+    expect(claimLayerIds([stutter]).skipped.get('stutter')?.map(conflict => conflict.rowId)).toEqual(['x'])
   })
 
   it('leaves out a bundle whose config override sets a row another layer owns', () => {
-    const hijack = layer('hijack', 'external', [
+    const hijack = layer('hijack', [
       { insert: [{ id: 'own', name: 'cordis:group', group: true, config: [] }] },
       { id: 'own', config: [{ id: 'settings', name: 'hijack/impostor' }] },
     ])
@@ -57,38 +63,38 @@ describe('claimLayerIds', () => {
         message: 'row "settings" is already declared by @deepseek-ai/dsh-base',
       },
     ])
-    const stack = composeProfileStack(NAME, [base, hijack], [])
+    const stack = composeProfileStack([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', [
+  it('lets a layer restate its own rows through a config override, and rejects another layer\'s', () => {
+    const restating = layer('restating', [
       { 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', [
+    const taking = layer('taking', [
       { 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/)
+    expect(claimLayerIds([base, taking]).skipped.get('taking')?.map(conflict => conflict.rowId)).toEqual(['settings'])
     // Setting a row under another group would move it; listing it twice would fail the group's update.
-    const moving = layer('moving', 'builtin', [
+    const moving = layer('moving', [
       { insert: [
         { id: 'g', name: 'cordis:group', group: true, config: [{ id: 'a', name: 'a' }] },
         { id: 'h', name: 'cordis:group', group: true, config: [] },
       ] },
       { id: 'h', config: [{ id: 'a', name: 'a' }] },
     ])
-    expect(() => claimLayerIds([moving])).toThrow(/row "a" is declared twice by moving/)
-    const twice = layer('twice', 'builtin', [
+    expect(claimLayerIds([moving]).skipped.get('moving')?.map(conflict => conflict.rowId)).toEqual(['a'])
+    const twice = layer('twice', [
       { insert: [{ id: 'g', name: 'cordis:group', group: true, config: [] }] },
       { id: 'g', config: [{ id: 'a', name: 'a' }, { id: 'a', name: 'a' }] },
     ])
-    expect(() => claimLayerIds([twice])).toThrow(/row "a" is declared twice by twice/)
+    expect(claimLayerIds([twice]).skipped.get('twice')?.map(conflict => conflict.rowId)).toEqual(['a'])
     // A child of a group without an id cannot be restated: no patch can address that group.
-    const anonymous = layer('anonymous', 'builtin', [
+    const anonymous = layer('anonymous', [
       { insert: [
         // A row leaves its id to the Loader, as YAML rows may; the type asks for one.
         { name: 'cordis:group', group: true, config: [{ id: 'a', name: 'a' }] } as unknown as EntryOptions,
@@ -96,11 +102,11 @@ describe('claimLayerIds', () => {
       ] },
       { id: 'h', config: [{ id: 'a', name: 'a' }] },
     ])
-    expect(() => claimLayerIds([anonymous])).toThrow(/row "a" is declared twice by anonymous/)
+    expect(claimLayerIds([anonymous]).skipped.get('anonymous')?.map(conflict => conflict.rowId)).toEqual(['a'])
   })
 
   it('leaves out a bundle whose config override of its own group lists an id twice', () => {
-    const self = layer('self', 'external', [
+    const self = layer('self', [
       { insert: [{ id: 'self-group', name: 'cordis:group', group: true, config: [{ id: 'row', name: 'self/row' }] }] },
       { id: 'self-group', config: [{ id: 'row', name: 'self/row' }, { id: 'row', name: 'self/row-again' }] },
     ])
@@ -110,21 +116,21 @@ describe('claimLayerIds', () => {
   })
 
   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' }] }])
+    const stutter = layer('stutter', [{ insert: [{ id: 'x', name: 'stutter/a' }, { id: 'x', name: 'stutter/b' }] }])
+    const clean = layer('clean', [{ insert: [{ id: 'y', name: 'clean' }] }])
     const { owners, skipped, composed } = claimLayerIds([base, stutter, clean])
     expect(skipped.get('stutter')).toEqual([
       { rowId: 'x', moduleName: 'stutter/b', layer: 'stutter', packageName: 'stutter', declaredBy: 'stutter', message: 'row "x" is declared twice by stutter' },
     ])
     expect(owners.has('x')).toBe(false)
     expect(owners.get('y')?.packageName).toBe('clean')
-    expect([...composed.keys()]).toEqual(['clean'])
+    expect([...composed.keys()]).toEqual(['@deepseek-ai/dsh-base', 'clean'])
     expect(composed.get('clean')?.patches[0]).toEqual({ insert: [{ id: 'y', name: 'clean' }] })
   })
 
   it('gives the earlier external bundle the id and leaves the later one out whole', () => {
-    const first = layer('first', 'external', [{ insert: [{ id: 'hello', name: 'first' }, { id: 'only-first', name: 'first/x' }] }])
-    const second = layer('second', 'external', [{ insert: [{ id: 'hello', name: 'second' }, { id: 'only-second', name: 'second/x' }] }])
+    const first = layer('first', [{ insert: [{ id: 'hello', name: 'first' }, { id: 'only-first', name: 'first/x' }] }])
+    const second = layer('second', [{ insert: [{ id: 'hello', name: 'second' }, { id: 'only-second', name: 'second/x' }] }])
     const { owners, skipped } = claimLayerIds([base, first, second])
     expect(owners.get('hello')?.packageName).toBe('first')
     expect(owners.has('bundle/first')).toBe(false)
@@ -135,7 +141,7 @@ 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: [
+    const stack = composeProfileStack([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 },
@@ -146,8 +152,8 @@ describe('composeProfileStack', () => {
   })
 
   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], [
+    const ext = layer('ext', [{ insert: [{ id: 'ext-tool', name: 'ext' }] }])
+    const stack = composeProfileStack([base, ext], [
       { label: '/p/cordis.patch.yml', patches: [
         { id: 'settings', config: { path: '/x' } },
         { insert: [{ id: 'mine', name: 'mine' }, { id: 'ext-tool', name: 'clash' }] },
@@ -183,8 +189,8 @@ describe('composeProfileStack', () => {
   })
 
   it('leaves a colliding external bundle out of the stack and reports it', () => {
-    const clash = layer('clash', 'external', [{ insert: [{ id: 'settings', name: 'clash' }] }])
-    const stack = composeProfileStack(NAME, [base, clash], [])
+    const clash = layer('clash', [{ insert: [{ id: 'settings', name: 'clash' }] }])
+    const stack = composeProfileStack([base, clash], [])
     expect(stack.layers.map(current => current.label)).toEqual(['@deepseek-ai/dsh-base'])
     expect(stack.skippedBundles).toEqual(['clash'])
     expect(stack.conflicts).toEqual([
@@ -195,10 +201,6 @@ describe('composeProfileStack', () => {
     ])
   })
 
-  it('prefixes a built-in duplicate with the binary name', () => {
-    const twin = layer('twin', 'builtin', [{ insert: [{ id: 'settings', name: 'twin' }] }])
-    expect(() => composeProfileStack(NAME, [base, twin], [])).toThrow(/^dsh-test-bin: row "settings" is declared by both/)
-  })
 })
 
 describe('formatRowConflict', () => {

+ 34 - 34
packages/boot/app-boot/tests/entry-issues.spec.ts

@@ -2,6 +2,7 @@
 import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { Context, type Plugin } from '@deepseek-ai/cordis'
 import Loader, { type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
@@ -26,8 +27,8 @@ const configured: Plugin.Object<{ value: number }> = {
 }
 const nested: Plugin.Function = (ctx) => { ctx.inject([], () => { throw new Error('nested failure') }) }
 const waiting: Plugin.Object = { inject: ['testService'], apply() {} }
-function layer(trust: ProfileLayer['trust'], stage: ProfileLayer['stage'], patches: PatchOptions[]): ProfileLayer {
-  return { packageName: 'pkg', version: undefined, packageDir: '/pkg', patchPath: '/pkg/patch.yml', trust, stage, patches }
+function layer(patches: PatchOptions[]): ProfileLayer {
+  return { packageName: 'pkg', version: undefined, packageDir: '/pkg', patchPath: '/pkg/patch.yml', patches }
 }
 async function profileBoot(layers: ProfileLayer[], user: PatchOptions[] = []): Promise<Context> {
   const dir = mkdtempSync(join(tmpdir(), 'dsh-entry-policy-'))
@@ -35,7 +36,7 @@ async function profileBoot(layers: ProfileLayer[], user: PatchOptions[] = []): P
   const path = join(dir, 'cordis.yml')
   writeFileSync(path, '[]\n')
   const profile: Profile = { name: 'test', dir, layers: [...layers], patches: [], patchPath: join(dir, 'cordis.patch.yml'), patchReload: 'live' }
-  const compose = (current: Profile) => composeProfileStack('test', current.layers, [{ label: profile.patchPath, patches: user }])
+  const compose = (current: Profile) => composeProfileStack(current.layers, [{ label: profile.patchPath, patches: user }])
   const stack = compose(profile)
   const ctx = await boot('test', path, stack.patches, async (ctx) => {
     Object.assign(ctx.loader.builtins, { good, bad, asyncBad, configured, waiting, nested })
@@ -55,32 +56,30 @@ const failures: EntryOptions[] = [
   { id: 'gate', name: 'cordis:good', disabled: { __jsExpr: 'missing.disabled' } as unknown as boolean },
 ]
 describe('entry startup policy', () => {
-  it.each(failures)('keeps a failing external runtime row and its successful sibling: $id', async (row) => {
-    const ctx = await profileBoot([layer('external', 'runtime', [{ insert: [row, { id: 'ok', name: 'cordis:good' }] }])])
+  it.each(failures)('keeps a failing bundle row and its successful sibling: $id', async (row) => {
+    const ctx = await profileBoot([layer([{ insert: [row, { id: 'ok', name: 'cordis:good' }] }])])
     expect(ctx.loader.resolve('include:ok').fiber?.state).toBe(2)
     const entry = ctx.loader.resolve(`include:${row.id}`)
     expect((await entryIssue(entry))?.message).toBeTruthy()
-    expect(ctx.profileRuntime.originOfEntry(entry)).toMatchObject({ trust: 'external', stage: 'runtime' })
+    expect(ctx.profileRuntime.originOfEntry(entry)).toMatchObject({ packageName: 'pkg' })
     expect([...ctx.loader.entries()].some(e => e.options.name === 'cordis:contained-group')).toBe(false)
   })
-  it.each([
-    ['builtin', 'boot'], ['builtin', 'runtime'], ['external', 'boot'],
-  ] as const)('rejects %s/%s failures regardless of module id', async (trust, stage) => {
-    for (const row of failures) {
-      await expect(profileBoot([layer(trust, stage, [{ insert: [row] }])])).rejects.toThrow('required startup failure')
-    }
+  it.each(failures)('rejects a required id even when a bundle introduces it: $id', async (row) => {
+    await expect(profileBoot([layer([{ insert: [{ ...row, id: 'webserver' }] }])])).rejects.toThrow('required startup failure')
   })
-  it('keeps user rows strict and does not transfer ownership with a config override', async () => {
-    await expect(profileBoot([], [{ insert: [{ id: 'user', name: 'cordis:bad' }] }])).rejects.toThrow('required startup failure')
-    const builtin = layer('builtin', 'runtime', [{ insert: [{ id: 'webserver', name: 'cordis:configured', config: { value: 1 } }] }])
-    const ext = { ...layer('external', 'runtime', [{ id: 'webserver', config: { value: -1 } }]), packageName: 'ext' }
-    await expect(profileBoot([builtin, ext])).rejects.toThrow('negative value')
+  it('keeps user rows and overrides of unlisted providers optional', async () => {
+    const builtin = layer([{ insert: [{ id: 'provider', name: 'cordis:configured', config: { value: 1 } }] }])
+    const ext = { ...layer([{ id: 'provider', config: { value: -1 } }]), packageName: 'ext' }
+    const ctx = await profileBoot([builtin, ext], [{ insert: [{ id: 'user', name: 'cordis:bad' }, { id: 'ok', name: 'cordis:good' }] }])
+    expect(ctx.loader.resolve('include:ok').fiber?.state).toBe(2)
+    expect((await inspectEntryIssues(ctx)).map(issue => issue.entry.options.id)).toEqual(['provider', 'user'])
+    expect(ctx.profileRuntime.originOf('provider')?.packageName).toBe('pkg')
   })
-  it('rejects a builtin consumer left pending by an optional provider failure', async () => {
+  it('rejects a required consumer left pending by an optional provider failure', async () => {
     await expect(profileBoot([
-      layer('builtin', 'boot', [{ insert: [{ id: 'consumer', name: 'cordis:waiting' }] }]),
-      { ...layer('external', 'runtime', [{ insert: [{ id: 'provider', name: 'cordis:bad' }] }]), packageName: 'ext' },
-    ])).rejects.toThrow('consumer')
+      layer([{ insert: [{ id: 'webserver', name: 'cordis:waiting' }] }]),
+      { ...layer([{ insert: [{ id: 'provider', name: 'cordis:bad' }] }]), packageName: 'ext' },
+    ])).rejects.toThrow('webserver')
   })
   it('preserves anonymous provenance and nested Include provenance without matching unrelated root ids', async () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-nested-entry-'))
@@ -88,26 +87,26 @@ describe('entry startup policy', () => {
     const path = join(dir, 'nested.yml')
     writeFileSync(path, '- id: shared\n  name: cordis:bad\n')
     const ctx = await profileBoot([
-      layer('builtin', 'boot', [{ insert: [{ id: 'shared', name: 'cordis:good' }] }]),
-      { ...layer('external', 'runtime', [{ insert: [
+      layer([{ insert: [{ id: 'shared', name: 'cordis:good' }] }]),
+      { ...layer([{ insert: [
         { name: 'cordis:good' } as EntryOptions,
-        { id: 'nested', name: 'cordis:include', config: { path } },
+        { id: 'nested', name: 'cordis:include', config: { path: pathToFileURL(path).href } },
       ] }]), packageName: 'ext' },
     ])
-    expect(ctx.profileRuntime.originOfEntry(ctx.loader.resolve('include:shared'))?.trust).toBe('builtin')
-    expect(ctx.profileRuntime.originOfEntry(ctx.loader.resolve('include:nested:shared'))?.trust).toBe('external')
+    expect(ctx.profileRuntime.originOfEntry(ctx.loader.resolve('include:shared'))?.packageName).toBe('pkg')
+    expect(ctx.profileRuntime.originOfEntry(ctx.loader.resolve('include:nested:shared'))?.packageName).toBe('ext')
     const anonymous = [...ctx.loader.entries()].find(e => e.options.id.startsWith('anonymous/'))!
     expect(ctx.profileRuntime.originOfEntry(anonymous)?.packageName).toBe('ext')
   })
 })
 describe('entry diagnostics', () => {
   it('disables every part of a bundle layer and restores overrides while preserving user row choices', async () => {
-    const base = layer('builtin', 'boot', [{ insert: [
+    const base = layer([{ insert: [
       { id: 'webserver', name: 'cordis:configured', config: { value: 1 } },
       { id: 'tools', name: 'cordis:group', group: true, config: [] },
       { id: 'ui', name: 'cordis:group', group: true, config: [] },
     ] }])
-    const ext = { ...layer('external', 'runtime', [
+    const ext = { ...layer([
       { insert: [{ id: 'own', name: 'cordis:group', group: true, config: [{ id: 'child', name: 'cordis:good' }] }] },
       { id: 'tools', insert: [{ id: 'tool', name: 'cordis:good' }] },
       { id: 'ui', insert: [{ id: 'panel', name: 'cordis:good' }] },
@@ -170,7 +169,7 @@ describe('entry diagnostics', () => {
       { id: 'config', name: 'cordis:configured', config: { value: 1 } },
       { id: 'ok', name: 'cordis:good' },
     ] }]
-    const ctx = await profileBoot([layer('external', 'runtime', patches)])
+    const ctx = await profileBoot([layer(patches)])
     let release!: () => void
     let started!: () => void
     const removing = new Promise<void>((resolve) => { started = resolve })
@@ -193,19 +192,20 @@ describe('entry diagnostics', () => {
 })
 
 describe('nested continuation diagnostics', () => {
-  it('reports required nested failures without treating them as entry activation failures', async () => {
+  it('reports nested failures without treating them as entry activation failures', async () => {
     const ctx = await profileBoot([
-      layer('builtin', 'boot', [{ insert: [{ id: 'required', name: 'cordis:nested' }] }]),
-      { ...layer('external', 'runtime', [{ insert: [
+      layer([{ insert: [{ id: 'required', name: 'cordis:nested' }] }]),
+      { ...layer([{ insert: [
         { id: 'optional', name: 'cordis:nested' }, { id: 'failed-entry', name: 'cordis:bad' },
       ] }]), packageName: 'ext' },
     ])
     await expect(ctx.plugin(() => { throw new Error('unowned failure') })).rejects.toThrow('unowned failure')
     await Promise.allSettled([...ctx.registry.values()].flatMap(runtime => [...runtime.fibers].map(fiber => fiber.await())))
     const lines: string[] = []
-    expect(warnNestedFiberFailures(ctx, 'test', (line) => { lines.push(line) })).toBe(1)
+    const count = warnNestedFiberFailures(ctx, 'test', (line) => { lines.push(line) })
+    expect(count, lines.join('\n')).toBe(2)
     expect(lines[0]).toContain('cordis:nested')
     expect(ctx.loader.resolve('include:required').fiber?.state).toBe(2)
-    expect(warnNestedFiberFailures(ctx, 'test')).toBe(1)
+    expect(warnNestedFiberFailures(ctx, 'test')).toBe(2)
   })
 })

+ 8 - 9
packages/boot/app-boot/tests/external-bundles.spec.ts

@@ -5,7 +5,7 @@ import { join } from 'node:path'
 import { afterEach, describe, expect, it } from 'vitest'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import { disableBundle, enableBundle, reconcileInstalledBundles, readProfileManifest, type ProfileLayer } from '../src/index.ts'
-import { analyzeBundleLayer, exportsBundlePatch, isOptionalRuntimeLayer } from '../src/external-bundles.ts'
+import { analyzeBundleLayer, exportsBundlePatch } from '../src/external-bundles.ts'
 const NAME = 'dsh-test-bin'
 const roots: string[] = []
 const tmp = (): string => {
@@ -15,7 +15,7 @@ const tmp = (): string => {
 }
 afterEach(() => { roots.splice(0).forEach((root) => { rmSync(root, { recursive: true, force: true }) }) })
 function layer(patches: PatchOptions[]): ProfileLayer {
-  return { packageName: 'ext', version: undefined, packageDir: '/nowhere', patchPath: '/nowhere/patch.yml', trust: 'external', stage: 'runtime', patches }
+  return { packageName: 'ext', version: undefined, packageDir: '/nowhere', patchPath: '/nowhere/patch.yml', patches }
 }
 describe('analyzeBundleLayer', () => {
   it('preserves parents, explicit ids and overrides across several groups', () => {
@@ -32,6 +32,12 @@ describe('analyzeBundleLayer', () => {
     expect(result.overrides).toEqual(['webserver'])
     expect(result.duplicates).toEqual([])
   })
+  it('keeps array-valued plugin configuration out of the row inventory', () => {
+    const result = analyzeBundleLayer(layer([{ id: 'provider', config: ['value', { value: 42 }, null] }]))
+    expect([...result.rows]).toEqual([])
+    expect(result.duplicates).toEqual([])
+    expect(result.overrides).toEqual(['provider'])
+  })
   it('rejects repeated insert ids but permits restating children in the same group', () => {
     const patches: PatchOptions[] = [
       { insert: [{ id: 'group', name: 'cordis:group', group: true, config: [{ id: 'child', name: 'ext/child' }] }] },
@@ -40,13 +46,6 @@ describe('analyzeBundleLayer', () => {
     ]
     expect(analyzeBundleLayer(layer(patches)).duplicates).toEqual([{ rowId: 'child', moduleName: 'ext/duplicate' }])
   })
-  it('makes only external runtime layers optional at startup', () => {
-    const ext = layer([])
-    expect(isOptionalRuntimeLayer(ext)).toBe(true)
-    expect(isOptionalRuntimeLayer({ ...ext, stage: 'boot' })).toBe(false)
-    expect(isOptionalRuntimeLayer({ ...ext, trust: 'builtin' })).toBe(false)
-    expect(isOptionalRuntimeLayer({ ...ext, trust: 'builtin', stage: 'boot' })).toBe(false)
-  })
 })
 
 /** Stage a profile directory whose node_modules holds the named packages. */

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

@@ -14,8 +14,8 @@ afterEach(async () => {
   await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
 })
 
-function layer(packageName: string, trust: ProfileLayer['trust'], patches: PatchOptions[], stage: ProfileLayer['stage'] = 'runtime'): ProfileLayer {
-  return { packageName, version: '2.0.0', packageDir: '/nowhere', patchPath: '/nowhere/p.yml', trust, stage, patches }
+function layer(packageName: string, patches: PatchOptions[]): ProfileLayer {
+  return { packageName, version: '2.0.0', packageDir: '/nowhere', patchPath: '/nowhere/p.yml', patches }
 }
 
 function profile(layers: ProfileLayer[]): Profile {
@@ -55,7 +55,7 @@ async function harness(
 
 describe('ProfileRuntime', () => {
   it('exposes the booted profile\'s facts', async () => {
-    const { runtime } = await harness([layer('@deepseek-ai/dsh-base', 'builtin', [])])
+    const { runtime } = await harness([layer('@deepseek-ai/dsh-base', [])])
     expect(runtime.profileName).toBe('web')
     expect(runtime.installAnchor).toBe('/install/package.json')
     expect(runtime.dir).toBe('/profiles/web')
@@ -67,44 +67,44 @@ describe('ProfileRuntime', () => {
 
   it('attributes rows to the layer that owns their id', async () => {
     const { runtime } = await harness([
-      layer('@deepseek-ai/dsh-base', 'builtin', [{ insert: [{ id: 'settings', name: 'x' }, { name: 'anonymous' } as EntryOptions, { id: 'grp', name: 'cordis:group', group: true, config: [{ id: 'child', name: 'y' }] }] }]),
-      layer('ext', 'external', [{ insert: [{ id: 'tool', name: 'ext' }] }]),
-      layer('boot-ext', 'external', [{ insert: [{ id: 'svc', name: 'boot-ext' }] }], 'boot'),
+      layer('@deepseek-ai/dsh-base', [{ insert: [{ id: 'settings', name: 'x' }, { name: 'anonymous' } as EntryOptions, { id: 'grp', name: 'cordis:group', group: true, config: [{ id: 'child', name: 'y' }] }] }]),
+      layer('ext', [{ insert: [{ id: 'tool', name: 'ext' }] }]),
+      layer('provider-ext', [{ insert: [{ id: 'svc', name: 'provider-ext' }] }]),
       // Declares an id the first external layer owns: left out whole, so none of its rows has an origin.
-      layer('late', 'external', [{ insert: [{ id: 'tool', name: 'late' }, { id: 'late-only', name: 'late/x' }] }]),
+      layer('late', [{ insert: [{ id: 'tool', name: 'late' }, { id: 'late-only', name: 'late/x' }] }]),
     ])
-    expect(runtime.originOf('settings')).toEqual({ trust: 'builtin', stage: 'runtime', packageName: '@deepseek-ai/dsh-base', version: '2.0.0' })
-    expect(runtime.originOf('child')).toEqual({ trust: 'builtin', stage: 'runtime', packageName: '@deepseek-ai/dsh-base', version: '2.0.0' })
-    expect(runtime.originOf('tool')).toEqual({ trust: 'external', stage: 'runtime', packageName: 'ext', version: '2.0.0' })
+    expect(runtime.originOf('settings')).toEqual({ packageName: '@deepseek-ai/dsh-base', version: '2.0.0' })
+    expect(runtime.originOf('child')).toEqual({ packageName: '@deepseek-ai/dsh-base', version: '2.0.0' })
+    expect(runtime.originOf('tool')).toEqual({ packageName: 'ext', version: '2.0.0' })
     expect(runtime.originOf('bundle/ext')).toBeUndefined()
-    expect(runtime.originOf('svc')).toEqual({ trust: 'external', stage: 'boot', packageName: 'boot-ext', version: '2.0.0' })
+    expect(runtime.originOf('svc')).toEqual({ packageName: 'provider-ext', version: '2.0.0' })
     expect(runtime.originOf('late-only')).toBeUndefined()
     expect(runtime.originOf('user-row')).toBeUndefined()
   })
 
   it('does not descend into a group row whose config is not a list', async () => {
     const { runtime } = await harness([
-      layer('odd', 'builtin', [{ insert: [{ id: 'g', name: 'cordis:group', group: true, config: {} as never }] }]),
+      layer('odd', [{ insert: [{ id: 'g', name: 'cordis:group', group: true, config: {} as never }] }]),
     ])
     expect(runtime.originOf('g')?.packageName).toBe('odd')
   })
 
   it('omits the version when the layer has none', async () => {
-    const { runtime } = await harness([{ ...layer('local', 'builtin', [{ insert: [{ id: 'r', name: 'local' }] }]), version: undefined }])
-    expect(runtime.originOf('r')).toEqual({ trust: 'builtin', stage: 'runtime', packageName: 'local' })
+    const { runtime } = await harness([{ ...layer('local', [{ insert: [{ id: 'r', name: 'local' }] }]), version: undefined }])
+    expect(runtime.originOf('r')).toEqual({ packageName: 'local' })
   })
 
   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 })
+    const reloaded = profile([layer('a', []), layer('b', [])])
+    const { runtime } = await harness([layer('a', [])], { 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', async () => {
-    const { runtime } = await harness([layer('a', 'builtin', [])])
+    const { runtime } = await harness([layer('a', [])])
     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
@@ -117,9 +117,9 @@ describe('ProfileRuntime', () => {
   it('recomposes through the root include, optionally re-reading the profile first, and commits on acceptance', async () => {
     const update = vi.fn(async () => {})
     const entry = { options: { config: { path: 'file:///root/cordis.yml', patches: [{ id: 'old' }] } }, update } as unknown as Entry
-    const reloaded = profile([layer('a', 'builtin', []), layer('b', 'external', [])])
+    const reloaded = profile([layer('a', []), layer('b', [])])
     const conflicts = [{ rowId: 'x', moduleName: 'm', layer: 'late', packageName: 'late', declaredBy: 'a', message: 'row "x" is already declared by a' }]
-    const { runtime, compose } = await harness([layer('a', 'builtin', [])], { rootEntry: () => entry, reloaded, conflicts })
+    const { runtime, compose } = await harness([layer('a', [])], { rootEntry: () => entry, reloaded, conflicts })
 
     await runtime.recompose()
     expect(compose).toHaveBeenLastCalledWith(expect.objectContaining({ layers: expect.any(Array) as ProfileLayer[] }))
@@ -138,7 +138,7 @@ describe('ProfileRuntime', () => {
   it('recomposes through a plugin context handle and publishes the accepted profile', async () => {
     const update = vi.fn(async () => {})
     const entry = { options: { config: { path: 'file:///root/cordis.yml' } }, update } as unknown as Entry
-    const reloaded = profile([layer('external', 'external', [])])
+    const reloaded = profile([layer('external', [])])
     const { ctx, runtime } = await harness([], { rootEntry: () => entry, reloaded })
     const caller = ctx.plugin({ inject: ['loader'], apply() {} })
     await caller.await()
@@ -159,8 +159,8 @@ describe('ProfileRuntime', () => {
       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 })
+    const reloaded = profile([layer('a', []), layer('b', [])])
+    const { runtime, compose } = await harness([layer('a', [])], { 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()
@@ -185,7 +185,7 @@ describe('ProfileRuntime', () => {
       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 { runtime } = await harness([layer('a', [])], { rootEntry: () => entry })
     const first = runtime.recompose()
     const second = runtime.recompose()
     await expect(first).rejects.toThrow('rejected')
@@ -195,9 +195,9 @@ describe('ProfileRuntime', () => {
 
   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', [])])
+    const reloaded = profile([layer('a', []), layer('b', [])])
     const conflicts = [{ rowId: 'x', moduleName: 'm', layer: 'late', packageName: 'late', declaredBy: 'a', message: 'row "x" is already declared by a' }]
-    const { runtime } = await harness([layer('a', 'builtin', [])], { rootEntry: () => entry, reloaded, conflicts })
+    const { runtime } = await harness([layer('a', [])], { rootEntry: () => entry, reloaded, conflicts })
     await expect(runtime.recompose({ reloadBundles: true })).rejects.toThrow('rejected')
     expect(runtime.current.layers).toHaveLength(1)
     expect(runtime.layers.map(current => current.packageName)).toEqual(['a'])

+ 2 - 53
packages/boot/app-boot/tests/profile.spec.ts

@@ -83,8 +83,8 @@ function stageProfile(home: string, name: string, bundleAnchor: string): Profile
       patchPath: join(bundleAnchor, '..', 'cordis.patch.yml'),
       patches: [],
       version: undefined,
-      trust: 'builtin',
-      stage: 'runtime',
+
+
     }],
     patchPath: join(dir, PROFILE_PATCH_FILENAME),
     patches: [],
@@ -204,51 +204,6 @@ describe('loadProfile', () => {
     expect(bare.patchReload).toBe('live')
   })
 
-  it('classifies layers by provenance and resolves each bundle\'s mount stage', () => {
-    const anchor = stageInstallation({
-      'in-box': { patch: '[]\n' },
-      'ext-runtime': { patch: '[]\n' },
-      'ext-boot': { patch: '[]\n' },
-      'ext-overridden': { patch: '[]\n' },
-      'ext-first-party': { patch: '[]\n' },
-    })
-    // The bundle author asks for boot stage on one package.
-    const bootManifest = join(anchor, '..', 'node_modules', 'ext-boot', 'package.json')
-    writeFileSync(bootManifest, JSON.stringify({ ...JSON.parse(readFileSync(bootManifest, 'utf8')) as object, dsh: { bundle: { patch: './cordis.patch.yml', stage: 'boot' } } }))
-    const home = tmp()
-    const dir = resolveProfileDir('demo', home)
-    initProfile(dir, ['in-box', 'ext-runtime', 'ext-boot', 'ext-overridden', 'ext-first-party'])
-    writeProfileManifest(dir, {
-      ...readProfileManifest('t', dir),
-      dependencies: { 'ext-runtime': '0.0.0', 'ext-boot': '0.0.0', 'ext-overridden': '0.0.0', 'ext-first-party': '0.0.0' },
-      dsh: {
-        profile: {
-          bundles: ['in-box', 'ext-runtime', 'ext-boot', 'ext-overridden', 'ext-first-party'],
-          stages: { 'ext-overridden': 'boot' },
-          firstParty: ['ext-first-party'],
-        },
-      },
-    })
-    const profile = loadProfile('t', 'demo', anchor, home)
-    expect(profile.layers.map(layer => [layer.packageName, layer.trust, layer.stage, layer.version])).toEqual([
-      ['in-box', 'builtin', 'runtime', '0.0.0'],
-      ['ext-runtime', 'external', 'runtime', '0.0.0'],
-      ['ext-boot', 'external', 'boot', '0.0.0'],
-      ['ext-overridden', 'external', 'boot', '0.0.0'],
-      ['ext-first-party', 'builtin', 'runtime', '0.0.0'],
-    ])
-  })
-
-  it('fails loud on a stage value it does not know', () => {
-    const anchor = stageInstallation({ 'ext-odd': { patch: '[]\n' } })
-    const oddManifest = join(anchor, '..', 'node_modules', 'ext-odd', 'package.json')
-    writeFileSync(oddManifest, JSON.stringify({ ...JSON.parse(readFileSync(oddManifest, 'utf8')) as object, dsh: { bundle: { patch: './cordis.patch.yml', stage: 'later' } } }))
-    const home = tmp()
-    const dir = resolveProfileDir('demo', home)
-    initProfile(dir, ['ext-odd'])
-    expect(() => loadProfile('t', 'demo', anchor, home)).toThrow('declares stage "later"; expected "boot" or "runtime"')
-  })
-
   it('auto-initializes only shipped templates and fails loud otherwise', () => {
     const anchor = stageInstallation({})
     const home = tmp()
@@ -488,8 +443,6 @@ describe('healProfilesModuleFallback', () => {
         patchPath: join(bundleLink, 'cordis.patch.yml'),
         patches: [],
         version: undefined,
-        trust: 'builtin' as const,
-        stage: 'runtime' as const,
       }],
       patchPath: join(dir, PROFILE_PATCH_FILENAME),
       patches: [],
@@ -536,8 +489,6 @@ describe('healProfilesModuleFallback', () => {
         patchPath: join(packageDir, 'cordis.patch.yml'),
         patches: [],
         version: undefined,
-        trust: 'builtin' as const,
-        stage: 'runtime' as const,
       })),
       patchPath: join(dir, PROFILE_PATCH_FILENAME),
       patches: [],
@@ -579,8 +530,6 @@ describe('healProfilesModuleFallback', () => {
         patchPath: join(packageDir, 'cordis.patch.yml'),
         patches: [],
         version: undefined,
-        trust: 'builtin' as const,
-        stage: 'runtime' as const,
       })),
       patchPath: join(dir, PROFILE_PATCH_FILENAME),
       patches: [],

+ 2 - 2
packages/boot/plugin-manager/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/plugin-manager/README.md
-README.md: 4281edec98760479c37f2a55a9e50efab7bd3880
-README.zh.md: 6c7e4adbb32d0d9748064b73d37d08484158e493
+README.md: 2ea3709d441ca9327048ffe0e001acf08020c33d
+README.zh.md: 6c0cc3d17f67f96ab41cb6e3625f1cfa536671a9

+ 1 - 1
packages/boot/plugin-manager/README.md

@@ -72,7 +72,7 @@ const manager = new PluginManager(ctx, {
 console.log(await manager.list())
 ```
 
-`list` reports package identity, `bundle` / `plugin` / `unknown` classification, trust, stage, installation and enablement. Runtime rows carry actual phases and failures; disabled bundles show static patch declarations. `addable` comes only from `dsh.plugins`, including `.` for the main export and declared defaults. An active row can retain its previous config after an update fails. Package `issues` also names failed rows its patch overrides, without transferring their ownership.
+`list` reports package identity, `bundle` / `plugin` / `unknown` classification, installation and enablement. Runtime rows carry actual phases and failures; disabled bundles show static patch declarations. `addable` comes only from `dsh.plugins`, including `.` for the main export and declared defaults. An active row can retain its previous config after an update fails. Package `issues` also names failed rows its patch overrides, without transferring their ownership.
 
 `enable` selects the whole bundle layer and recomposes live profiles. Per-row failures retain the enabled choice and successful siblings; results report `issues`, and the list can show `partial` or `failed`. Preparation failures revert the enable selection and raise `plugins/enable-failed`. `disable` removes the whole layer, including overrides. `retry` disables, awaits cleanup, and enables again. Startup-only profiles report `effect: restart`. Unapplied selections on live profiles report `failed`, preserving the rows actually running; `restart-required` is reserved for startup-only profiles. `uninstall` disables the bundle, removes user-inserted references, and runs pnpm remove.
 

+ 1 - 1
packages/boot/plugin-manager/README.zh.md

@@ -72,7 +72,7 @@ const manager = new PluginManager(ctx, {
 console.log(await manager.list())
 ```
 
-`list` 报告包身份、`bundle` / `plugin` / `unknown` 分类、trust、stage、安装与启用状态。运行行携带实际阶段与失败;禁用组合包显示静态 patch 声明。`addable` 只来自 `dsh.plugins`,包括表示主入口的 `.` 和声明的默认配置。行更新失败后,活跃实例可能保留先前配置。包的 `issues` 还报告其 patch 覆盖的失败行,但不转移这些行的归属。
+`list` 报告包身份、`bundle` / `plugin` / `unknown` 分类、安装与启用状态。运行行携带实际阶段与失败;禁用组合包显示静态 patch 声明。`addable` 只来自 `dsh.plugins`,包括表示主入口的 `.` 和声明的默认配置。行更新失败后,活跃实例可能保留先前配置。包的 `issues` 还报告其 patch 覆盖的失败行,但不转移这些行的归属。
 
 `enable` 选择整份组合包层,并在实时 profile 中重组。逐行失败保留启用选择与成功的其他行;结果报告 `issues`,列表可显示 `partial` 或 `failed`。准备失败会撤销启用选择,并抛出 `plugins/enable-failed`。`disable` 移除整层,包括覆盖。`retry` 先禁用并等待清理,再启用。仅启动时生效的 profile 报告 `effect: restart`。实时 profile 中尚未应用的选择报告 `failed`,并保留实际运行的行;`restart-required` 只用于仅启动时生效的 profile。`uninstall` 禁用组合包、删除用户插入的引用、执行 pnpm remove。
 

+ 2 - 2
packages/boot/plugin-manager/src/installer.ts

@@ -201,7 +201,7 @@ export class PluginInstaller {
   }
 
   /**
-   * Check a new bundle against the profile’s row ownership and stage rules.
+   * Check a new bundle against the profile’s row ownership rules.
    * Undeclared or unreadable packages remain installed for repair or removal.
    * @returns why the package is removed again, or undefined to keep it.
    */
@@ -215,7 +215,7 @@ export class PluginInstaller {
     if (metadata.kind !== 'bundle') return undefined
     const { profileDir, installAnchor } = this.options
     try {
-      const layer = resolveProfileLayer(NAME, readProfileManifest(NAME, profileDir), packageName, installAnchor, profileDir)
+      const layer = resolveProfileLayer(NAME, packageName, installAnchor, profileDir)
       const lost = claimLayerIds([...this.options.loadProfile().layers, layer]).skipped.get(packageName)
       if (lost === undefined) return undefined
       return lost.map(conflict => `row ${JSON.stringify(conflict.rowId)} is already declared by ${conflict.declaredBy}`).join('; ')

+ 1 - 9
packages/boot/plugin-manager/src/types.ts

@@ -10,12 +10,6 @@ import type { JsonValue } from '@deepseek-ai/dsh-util-values'
 /** What an installed package is: a bundle layer, declared plugin modules, or an unknown package. */
 export type PluginPackageKind = 'bundle' | 'plugin' | 'unknown'
 
-/** Who supplied a package: the installation's own bundles, or a dependency the user installed. */
-export type PluginPackageTrust = 'builtin' | 'external'
-
-/** Startup failure policy: external runtime rows are optional; boot rows are required. */
-export type PluginPackageStage = 'boot' | 'runtime'
-
 /**
  * The state one package is in, folded from the manifest, declarations, and current entries:
  *
@@ -81,8 +75,6 @@ export interface PluginPackageView {
   /** Free text from the manifest. */
   readonly description?: string
   readonly kind: PluginPackageKind
-  readonly trust: PluginPackageTrust
-  readonly stage: PluginPackageStage
   /** Whether the package is a profile dependency (installed by `dsh plugin add` or the manager). */
   readonly installed: boolean
   /** Whether the bundle is in the profile's layer list. */
@@ -115,7 +107,7 @@ export type PluginRowTarget =
 export interface PluginInstallRejection {
   /** The package name. */
   readonly name: string
-  /** Why the bundle was rejected, such as a conflicting row id or invalid stage. */
+  /** Why the bundle was rejected, such as a conflicting row id or unreadable composition. */
   readonly reason: string
 }
 

+ 1 - 7
packages/boot/plugin-manager/src/view.ts

@@ -7,8 +7,7 @@
 
 import type { Context, FiberState } from '@deepseek-ai/cordis'
 import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
-import { rootIncludeEntry, layerTrust, type EntryIssue, type PackageMetadata, type ProfileManifest, type ProfileRuntime } from '@deepseek-ai/dsh-app-boot'
-import type { BundleStage } from '@deepseek-ai/dsh-package-manifest'
+import { rootIncludeEntry, type EntryIssue, type PackageMetadata, type ProfileManifest, type ProfileRuntime } from '@deepseek-ai/dsh-app-boot'
 import { bundlesOf, dependenciesOf, messageOf, optional } from './helpers.ts'
 import type { PluginInstaller } from './installer.ts'
 import { addableView } from './modules.ts'
@@ -61,7 +60,6 @@ export function packageView(
   const installed = name in dependenciesOf(manifest)
   const enabled = bundlesOf(manifest).includes(name)
   const layer = runtime.layers.find(candidate => candidate.packageName === name)
-  const trust = layer?.trust ?? layerTrust(manifest, name)
   const liveReload = runtime.patchReload === 'live'
   let metadata: PackageMetadata | undefined
   let metadataFailure: string | undefined
@@ -78,8 +76,6 @@ export function packageView(
   } catch (error) {
     metadataFailure = messageOf(error)
   }
-  const stage: BundleStage = layer?.stage
-    ?? (manifest.dsh?.profile?.stages?.[name] ?? packageManifest?.dsh?.bundle?.stage ?? 'runtime')
   const kind = metadata?.kind ?? (layer !== undefined || packageManifest?.dsh?.bundle !== undefined ? 'bundle' : 'unknown')
   const composed = layer !== undefined
   const rootTree = rootIncludeEntry(ctx.root)?.subtree
@@ -100,8 +96,6 @@ export function packageView(
     ...optional('title', packageManifest?.dsh?.title),
     ...optional('description', packageManifest?.description),
     kind,
-    trust,
-    stage,
     installed,
     enabled,
     status,

+ 41 - 42
packages/boot/plugin-manager/tests/plugin-manager.spec.ts

@@ -19,7 +19,7 @@ import { Context, type Plugin } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import {
   boot, composeProfileStack, loadOptionalPatches, loadProfile, ProfileRuntime, rootIncludeEntry,
-  type ComposedStack, type Profile, type readPackageMetadata,
+  type ComposedStack, type Profile, readPackageMetadata,
 } from '@deepseek-ai/dsh-app-boot'
 import {
   PluginManager, PluginOperationError, pluginOperationFailureOf,
@@ -101,8 +101,6 @@ interface StagedPackage {
   /** `index.js` text, exported as the package main. */
   main?: string
   version?: string
-  /** Any string: a staged manifest may declare a stage the profile refuses. */
-  stage?: string
   plugins?: { name: string; title?: string; config?: unknown }[]
   files?: Record<string, string>
 }
@@ -119,7 +117,7 @@ function stagePackage(profileDir: string, name: string, staged: StagedPackage):
     ...staged.main === undefined ? {} : { main: 'index.js' },
     dsh: {
       title: `Title of ${name}`,
-      ...staged.patch === undefined ? {} : { bundle: { patch: './cordis.patch.yml', ...staged.stage === undefined ? {} : { stage: staged.stage } } },
+      ...staged.patch === undefined ? {} : { bundle: { patch: './cordis.patch.yml' } },
       ...staged.plugins === undefined ? {} : { plugins: staged.plugins },
     },
   }, null, 2))
@@ -211,7 +209,7 @@ interface Booted {
 async function bootProfile(staged: StagedHome, internals: Internals = {}, config: Partial<PluginToolingConfig> = {}): Promise<Booted> {
   const load = (): Profile => loadProfile(NAME, 'web', staged.anchor, staged.home)
   const composeFor = (profile: Profile): ComposedStack => {
-    const stack = composeProfileStack(NAME, profile.layers, [
+    const stack = composeProfileStack(profile.layers, [
       { label: profile.patchPath, patches: loadOptionalPatches(NAME, profile.patchPath) ?? [] },
     ])
     return { ...stack, patches: structuredClone(stack.patches) }
@@ -324,11 +322,14 @@ describe('PluginManager', () => {
     const manifest = manifestOf(staged.profileDir)
     manifest.dsh.profile.bundles.push('base')
     writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest))
-    stagePackage(staged.profileDir, 'clash', { patch: BUNDLE_ONE_ROW, stage: 'boot' })
+    stagePackage(staged.profileDir, 'clash', { patch: BUNDLE_ONE_ROW })
     addDependency(staged.profileDir, 'clash')
     const { ctx, manager } = await bootProfile(staged)
+    const changedManifest = manifestOf(staged.profileDir)
+    changedManifest.dsh.profile.bundles.push('missing-base')
+    writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(changedManifest))
     await expect(manager.enable('clash')).rejects.toMatchObject({ code: 'plugins/enable-failed' })
-    expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['base'])
+    expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['base', 'missing-base'])
     expect(ctx.loader.resolve('include:hello').fiber?.state).toBe(2)
   })
 
@@ -375,13 +376,13 @@ describe('PluginManager', () => {
 
       const views = await manager.list()
 
-      expect(views.map(view => [view.name, view.kind, view.status, view.installed, view.enabled, view.trust])).toEqual([
-        ['ext-bundle', 'bundle', 'disabled', true, false, 'external'],
-        ['ext-lib', 'unknown', 'plain', true, false, 'external'],
-        ['ext-plugin', 'plugin', 'plain', true, false, 'external'],
+      expect(views.map(view => [view.name, view.kind, view.status, view.installed, view.enabled])).toEqual([
+        ['ext-bundle', 'bundle', 'disabled', true, false],
+        ['ext-lib', 'unknown', 'plain', true, false],
+        ['ext-plugin', 'plugin', 'plain', true, false],
       ])
       const bundle = views[0]
-      expect(bundle).toMatchObject({ version: '1.0.0', title: 'Title of ext-bundle', description: 'staged ext-bundle', stage: 'runtime', liveReload: true })
+      expect(bundle).toMatchObject({ version: '1.0.0', title: 'Title of ext-bundle', description: 'staged ext-bundle', liveReload: true })
       // Rows come from static declarations while the bundle is not composed, under the ids the patch declares.
       expect(bundle?.rows).toEqual([{ entryId: 'hello', rowId: 'hello', moduleName: 'cordis:good', enabled: true, phase: null }])
       expect(bundle?.addable).toEqual([{ moduleName: 'ext-bundle/extra.js', declaredName: './extra.js', title: 'Extra' }])
@@ -514,7 +515,7 @@ describe('PluginManager', () => {
       stagePackage(staged.profileDir, 'tpl', { patch: BUNDLE_ONE_ROW })
       writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web', dsh: { profile: { bundles: ['tpl'], patchReload: 'startup' } } }))
       const { manager } = await bootProfile(staged)
-      expect((await manager.list()).map(view => [view.name, view.trust, view.status, view.installed, view.rows.length])).toEqual([['tpl', 'builtin', 'running', false, 1]])
+      expect((await manager.list()).map(view => [view.name, view.status, view.installed, view.rows.length])).toEqual([['tpl', 'running', false, 1]])
       // A manifest with no dsh section at all knows no bundles.
       writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web' }))
       expect(await manager.list()).toEqual([])
@@ -523,10 +524,10 @@ describe('PluginManager', () => {
       stagePackage(staged.profileDir, 'tpl-later', { patch: BUNDLE_ONE_ROW })
       writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web', dsh: { profile: { bundles: ['tpl', 'tpl-later', 'ghost'], patchReload: 'startup' } } }))
       const views = await manager.list()
-      expect(views.map(view => [view.name, view.trust, view.kind, view.status])).toEqual([
-        ['tpl', 'builtin', 'bundle', 'running'],
-        ['tpl-later', 'builtin', 'bundle', 'restart-required'],
-        ['ghost', 'builtin', 'unknown', 'plain'],
+      expect(views.map(view => [view.name, view.kind, view.status])).toEqual([
+        ['tpl', 'bundle', 'running'],
+        ['tpl-later', 'bundle', 'restart-required'],
+        ['ghost', 'unknown', 'plain'],
       ])
       await expect(manager.uninstall('ghost')).rejects.toMatchObject({ code: 'plugins/not-installed' })
       // Retrying takes the bundle out first, which a template bundle refuses.
@@ -564,11 +565,8 @@ describe('PluginManager', () => {
       const staged = await stageHome()
       stagePackage(staged.profileDir, 'ext-anon', { patch: '- insert:\n    - name: cordis:good\n    - id: gated\n      name: cordis:good\n      disabled: true\n' })
       addDependency(staged.profileDir, 'ext-anon')
-      stagePackage(staged.profileDir, 'fp-bundle', { patch: BUNDLE_ONE_ROW })
-      addDependency(staged.profileDir, 'fp-bundle')
-      const manifest = JSON.parse(readFileSync(join(staged.profileDir, 'package.json'), 'utf8')) as { dsh: { profile: Record<string, unknown> } }
-      manifest.dsh.profile.firstParty = ['fp-bundle']
-      writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest))
+      stagePackage(staged.profileDir, 'installed-bundle', { patch: BUNDLE_ONE_ROW })
+      addDependency(staged.profileDir, 'installed-bundle')
       const { manager } = await bootProfile(staged)
 
       const views = await manager.list()
@@ -577,12 +575,12 @@ describe('PluginManager', () => {
         { entryId: 'cordis:good', rowId: 'cordis:good', moduleName: 'cordis:good', enabled: true, phase: null },
         { entryId: 'gated', rowId: 'gated', moduleName: 'cordis:good', enabled: false, disabledBy: 'composition', phase: null },
       ])
-      await manager.enable('fp-bundle')
-      const firstParty = (await manager.list()).find(view => view.name === 'fp-bundle')
-      expect(firstParty).toMatchObject({ trust: 'builtin', status: 'running' })
-      expect(firstParty?.rows.map(row => row.entryId)).toEqual(['include:hello'])
-      await manager.disable('fp-bundle')
-      expect((await manager.list()).find(view => view.name === 'fp-bundle')?.rows.map(row => row.entryId)).toEqual(['hello'])
+      await manager.enable('installed-bundle')
+      const installed = (await manager.list()).find(view => view.name === 'installed-bundle')
+      expect(installed).toMatchObject({ status: 'running' })
+      expect(installed?.rows.map(row => row.entryId)).toEqual(['include:hello'])
+      await manager.disable('installed-bundle')
+      expect((await manager.list()).find(view => view.name === 'installed-bundle')?.rows.map(row => row.entryId)).toEqual(['hello'])
     })
   })
 
@@ -625,22 +623,23 @@ describe('PluginManager', () => {
       expect(manifestOf(staged.profileDir).dependencies).not.toHaveProperty('ext-two')
     })
 
-    it('removes an installed bundle the profile cannot resolve and says why', async () => {
+    it('reports a bundle whose patch disappears after metadata discovery', async () => {
       const staged = await stageHome()
-      // The static reader reads the manifest without judging the stage; resolving the
-      // layer is what refuses it, and that refusal is the removal's reason.
-      stagePackage(staged.profileDir, 'ext-odd', { patch: BUNDLE_ONE_ROW, stage: 'weird' })
+      stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
       const calls: string[][] = []
-      const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
+      const { manager } = await bootProfile(staged, {
+        spawn: recordingPnpm(staged.profileDir, calls),
+        metadata: (options) => {
+          const metadata = readPackageMetadata(options)
+          rmSync(join(staged.profileDir, 'node_modules/ext-new/cordis.patch.yml'))
+          return metadata
+        },
+      })
 
-      const result = await manager.add('ext-odd')
+      const result = await manager.add('ext-new')
 
-      expect(result).toMatchObject({
-        installed: [], installedOnly: [],
-        removed: [{ name: 'ext-odd', reason: expect.stringContaining('declares stage "weird"') as string }],
-      })
-      expect(calls).toEqual([['pnpm', 'add', 'ext-odd'], ['pnpm', 'remove', 'ext-odd']])
-      expect(manifestOf(staged.profileDir).dependencies).not.toHaveProperty('ext-odd')
+      expect(result.removed).toEqual([{ name: 'ext-new', reason: expect.stringContaining('cordis.patch.yml') as string }])
+      expect(calls).toEqual([['pnpm', 'add', 'ext-new'], ['pnpm', 'remove', 'ext-new']])
     })
 
     it('keeps an unexecuted package installed with its bundle disabled', async () => {
@@ -866,9 +865,9 @@ describe('PluginManager', () => {
       await expect(manager.retry('ext-lib')).rejects.toMatchObject({ code: 'plugins/bad-request' })
     })
 
-    it('retains live enablement and reports failed boot-stage rows after startup', async () => {
+    it('retains live enablement and reports failed rows after startup', async () => {
       const staged = await stageHome()
-      stagePackage(staged.profileDir, 'ext-fatal', { patch: '- insert:\n    - id: bad\n      name: cordis:throws\n', stage: 'boot' })
+      stagePackage(staged.profileDir, 'ext-fatal', { patch: '- insert:\n    - id: bad\n      name: cordis:throws\n' })
       addDependency(staged.profileDir, 'ext-fatal')
       stagePackage(staged.profileDir, 'ext-fine', { patch: BUNDLE_ONE_ROW })
       addDependency(staged.profileDir, 'ext-fine')

+ 0 - 1
packages/bundle/web-app/tests/web-app.spec.ts

@@ -323,7 +323,6 @@ describe('web-app runtime glue', () => {
   ])('announces readiness=$announces after the $id sibling fails', async ({ id, announces }) => {
     stageDist()
     const ctx = new Context()
-    ctx.provide('profileRuntime', { originOfEntry: () => announces ? { trust: 'external', stage: 'runtime' } : undefined } as unknown as AppBoot.ProfileRuntime)
     onTestFinished(() => ctx.fiber.dispose())
     ctx.baseUrl = 'file:///'
     ctx.provide('webServer', fakeHttpServer().server)

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

@@ -3955,14 +3955,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'BrandedNumber',
     declaration: 'export type BrandedNumber<B extends string> = number & {\n    readonly [BRAND]: B;\n};',
   },
-  {
-    name: 'BundleStage',
-    declaration: 'export type BundleStage = \'boot\' | \'runtime\';',
-  },
-  {
-    name: 'BundleTrust',
-    declaration: 'export type BundleTrust = \'builtin\' | \'external\';',
-  },
   {
     name: 'ClientArtifactBaseline',
     declaration: 'export interface ClientArtifactBaseline {\n    readonly path: string;\n    readonly mtimeMs: number;\n    readonly size: number;\n}',
@@ -4955,21 +4947,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'PluginPackageRowView',
     declaration: 'export interface PluginPackageRowView {\n    readonly entryId: string;\n    readonly rowId: string;\n    readonly moduleName: string;\n    readonly enabled: boolean;\n    readonly disabledBy?: \'user\' | \'composition\';\n    readonly phase: PluginRowPhase;\n    readonly failure?: {\n        readonly stage: string;\n        readonly message: string;\n    };\n}',
   },
-  {
-    name: 'PluginPackageStage',
-    declaration: 'export type PluginPackageStage = \'boot\' | \'runtime\';',
-  },
   {
     name: 'PluginPackageStatus',
     declaration: 'export type PluginPackageStatus = \'running\' | \'partial\' | \'failed\' | \'disabled\' | \'not-enableable\' | \'restart-required\' | \'plain\';',
   },
-  {
-    name: 'PluginPackageTrust',
-    declaration: 'export type PluginPackageTrust = \'builtin\' | \'external\';',
-  },
   {
     name: 'PluginPackageView',
-    declaration: 'export interface PluginPackageView {\n    readonly name: string;\n    readonly version?: string;\n    readonly title?: string;\n    readonly description?: string;\n    readonly kind: PluginPackageKind;\n    readonly trust: PluginPackageTrust;\n    readonly stage: PluginPackageStage;\n    readonly installed: boolean;\n    readonly enabled: boolean;\n    readonly status: PluginPackageStatus;\n    readonly reason?: string;\n    readonly enginesDsh?: string;\n    readonly cordisSameCopy: boolean | null;\n    readonly rows: readonly PluginPackageRowView[];\n    readonly issues?: readonly PluginRowIssue[];\n    readonly overrides: readonly string[];\n    readonly addable: readonly PluginPackageAddableView[];\n    readonly liveReload: boolean;\n}',
+    declaration: 'export interface PluginPackageView {\n    readonly name: string;\n    readonly version?: string;\n    readonly title?: string;\n    readonly description?: string;\n    readonly kind: PluginPackageKind;\n    readonly installed: boolean;\n    readonly enabled: boolean;\n    readonly status: PluginPackageStatus;\n    readonly reason?: string;\n    readonly enginesDsh?: string;\n    readonly cordisSameCopy: boolean | null;\n    readonly rows: readonly PluginPackageRowView[];\n    readonly issues?: readonly PluginRowIssue[];\n    readonly overrides: readonly string[];\n    readonly addable: readonly PluginPackageAddableView[];\n    readonly liveReload: boolean;\n}',
   },
   {
     name: 'PluginRowAddition',
@@ -5197,7 +5181,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'RowOrigin',
-    declaration: 'export interface RowOrigin {\n    readonly trust: BundleTrust;\n    readonly stage: BundleStage;\n    readonly packageName: string;\n    readonly version?: string;\n}',
+    declaration: 'export interface RowOrigin {\n    readonly packageName: string;\n    readonly version?: string;\n}',
   },
   {
     name: 'RunnerFailureRule',

+ 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: dd3d6d76abeddad88ca097f8988ecaa560e7e212
-README.zh.md: 34068a91c110f65331c6104e06e678c7f966c63d
+README.md: f0278bc208fdf6632468b2a1136704e1ddcb56ef
+README.zh.md: dafa8add4aa292a8b5b8e362532a450d3d73d39d

+ 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.
 
-`trust` identifies the supplying bundle, `package` names it with its version, and `disabledBy` distinguishes a user override from a composition condition. Explicit row ids remain unchanged. Failed entries remain in the Loader tree; `failure` reports import, activation, update, disabled-expression, or missing-service issues. An `active` fiber may still have an update failure and run its previous valid config. Conflicting rows omitted before loading are projected from `ProfileRuntime.conflicts`. Without profile provenance, rows carry no package.
+`package` names the supplying bundle with its version, and `disabledBy` distinguishes a user override from a composition condition. Explicit row ids remain unchanged. Failed entries remain in the Loader tree; `failure` reports import, activation, update, disabled-expression, or missing-service issues. An `active` fiber may still have an update failure and run its previous valid config. Conflicting rows omitted before loading are projected from `ProfileRuntime.conflicts`. Without profile provenance, rows carry no package.
 
 ### 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 行会被跳过。
 
-`trust` 标识提供该行的组合包,`package` 给出包名与版本,`disabledBy` 区分用户覆盖与组合条件。显式行 id 保持不变。失败条目留在 Loader 树中;`failure` 报告导入、激活、更新、disabled 表达式或缺失服务问题。`active` fiber 也可能更新失败,并继续运行先前的有效配置。加载前因冲突被排除的行从 `ProfileRuntime.conflicts` 投影。没有 profile 来源时,行不携带 package。
+`package` 给出提供该行的组合包名与版本,`disabledBy` 区分用户覆盖与组合条件。显式行 id 保持不变。失败条目留在 Loader 树中;`failure` 报告导入、激活、更新、disabled 表达式或缺失服务问题。`active` fiber 也可能更新失败,并继续运行先前的有效配置。加载前因冲突被排除的行从 `ProfileRuntime.conflicts` 投影。没有 profile 来源时,行不携带 package。
 
 ### 每个预设的组合
 

+ 0 - 2
packages/host/plugin-inventory/src/index.ts

@@ -90,7 +90,6 @@ export class PluginInventoryGateway extends TypertRemoteService {
         moduleName: entry.options.name,
         enabled,
         fiberPhase: entry.fiber === undefined ? (failure === undefined ? null : 'failed') : FIBER_PHASE[entry.fiber.state],
-        trust: origin?.trust ?? 'builtin',
         ...origin === undefined ? {} : { package: packageRef(origin) },
         ...enabled ? {} : { disabledBy: runtime?.userDisables(entry) === true ? 'user' as const : 'composition' as const },
         ...failure === undefined ? {} : { failure: { stage: failure.stage, message: failure.message } },
@@ -106,7 +105,6 @@ export class PluginInventoryGateway extends TypertRemoteService {
           moduleName: conflict.moduleName,
           enabled: true,
           fiberPhase: 'failed',
-          trust: conflict.packageName === undefined ? 'user' : 'external',
           ...conflict.packageName === undefined
             ? {}
             : { package: packageRef({ packageName: conflict.packageName, ...version === undefined ? {} : { version } }) },

+ 0 - 8
packages/host/plugin-inventory/src/types.ts

@@ -12,12 +12,6 @@ export type PluginFiberPhase =
   | 'unloading'
   | null
 
-/**
- * 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'
 
@@ -45,8 +39,6 @@ export interface PluginInventoryEntry {
   /** Effective Loader enablement, including disabled ancestor groups. */
   readonly enabled: boolean
   readonly fiberPhase: PluginFiberPhase
-  /** Who supplied the row; `builtin` when no profile runtime is composed. */
-  readonly trust: PluginTrust
   /** The bundle package that inserted the row, when one did. */
   readonly package?: PluginPackageRef
   /** Present exactly when `enabled` is false. */

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

@@ -64,14 +64,14 @@ describe('PluginInventoryGateway', () => {
         moduleName: 'cordis:active',
         enabled: true,
         fiberPhase: 'active',
-        trust: 'builtin',
+
       },
       {
         entryId: pendingId,
         moduleName: 'cordis:pending',
         enabled: true,
         fiberPhase: 'pending',
-        trust: 'builtin',
+
         failure: { stage: 'inject-pending', message: 'pending (waiting for service: neverReady)' },
       },
       {
@@ -79,7 +79,7 @@ describe('PluginInventoryGateway', () => {
         moduleName: 'cordis:not-installed',
         enabled: false,
         fiberPhase: null,
-        trust: 'builtin',
+
         disabledBy: 'composition',
       },
     ]))
@@ -90,7 +90,7 @@ describe('PluginInventoryGateway', () => {
       moduleName: 'cordis:active',
       enabled: false,
       fiberPhase: null,
-      trust: 'builtin',
+
       disabledBy: 'composition',
     })
 
@@ -105,10 +105,10 @@ describe('PluginInventoryGateway', () => {
     const bare = await ctx.loader.create({ name: 'cordis:active' })
     const off = await ctx.loader.create({ name: 'cordis:active', disabled: true })
     const origins = new Map<string, RowOrigin>([
-      [versioned, { trust: 'external', stage: 'runtime', packageName: 'ext', version: '1.2.3' }],
-      [bare, { trust: 'external', stage: 'runtime', packageName: 'ext' }],
-      [off, { trust: 'external', stage: 'runtime', packageName: 'ext' }],
-      ['gone', { trust: 'external', stage: 'runtime', packageName: 'ext' }],
+      [versioned, { packageName: 'ext', version: '1.2.3' }],
+      [bare, { packageName: 'ext' }],
+      [off, { packageName: 'ext' }],
+      ['gone', { packageName: 'ext' }],
     ])
     ctx.provide('profileRuntime', {
       originOfEntry: (entry: { id: string }) => origins.get(entry.id),
@@ -131,24 +131,24 @@ describe('PluginInventoryGateway', () => {
     // digits is listed first by the entry store regardless of creation order.
     const byId = (left: { entryId: string }, right: { entryId: string }): number => left.entryId.localeCompare(right.entryId)
     expect([...entries].sort(byId)).toEqual([
-      { entryId: versioned, moduleName: 'cordis:active', enabled: true, fiberPhase: 'active', trust: 'external', package: { name: 'ext', version: '1.2.3' } },
-      { entryId: bare, moduleName: 'cordis:active', enabled: true, fiberPhase: 'active', trust: 'external', package: { name: 'ext' }, failure: { stage: 'update', message: 'invalid config' } },
-      { entryId: off, moduleName: 'cordis:active', enabled: false, fiberPhase: null, trust: 'external', package: { name: 'ext' }, disabledBy: 'user' },
+      { entryId: versioned, moduleName: 'cordis:active', enabled: true, fiberPhase: 'active', package: { name: 'ext', version: '1.2.3' } },
+      { entryId: bare, moduleName: 'cordis:active', enabled: true, fiberPhase: 'active', package: { name: 'ext' }, failure: { stage: 'update', message: 'invalid config' } },
+      { entryId: off, moduleName: 'cordis:active', enabled: false, fiberPhase: null, package: { name: 'ext' }, disabledBy: 'user' },
       // A failed row the tree no longer holds is attributed through the runtime.
-      { entryId: 'gone', moduleName: './dsh-missing-fixture.mjs', enabled: true, fiberPhase: 'failed', trust: 'external', package: { name: 'ext' }, failure: { stage: 'import', message: 'failed to import: boom' } },
-      { entryId: 'orphan', moduleName: './dsh-missing-fixture.mjs', enabled: true, fiberPhase: 'failed', trust: 'builtin', failure: { stage: 'import', message: 'failed to import: lost' } },
+      { entryId: 'gone', moduleName: './dsh-missing-fixture.mjs', enabled: true, fiberPhase: 'failed', package: { name: 'ext' }, failure: { stage: 'import', message: 'failed to import: boom' } },
+      { entryId: 'orphan', moduleName: './dsh-missing-fixture.mjs', enabled: true, fiberPhase: 'failed', failure: { stage: 'import', message: 'failed to import: lost' } },
       // A row the composition left out is listed from the runtime's conflicts, under the layer that lost.
       {
-        entryId: 'conflict:late:tool', moduleName: 'late', enabled: true, fiberPhase: 'failed', trust: 'external',
+        entryId: 'conflict:late:tool', moduleName: 'late', enabled: true, fiberPhase: 'failed',
         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: 'user',
+        entryId: 'conflict:/p/cordis.patch.yml:mine', moduleName: 'twice', enabled: true, fiberPhase: 'failed',
         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.
       {
-        entryId: 'conflict:gone:x', moduleName: 'gone/x', enabled: true, fiberPhase: 'failed', trust: 'external',
+        entryId: 'conflict:gone:x', moduleName: 'gone/x', enabled: true, fiberPhase: 'failed',
         package: { name: 'gone' }, failure: { stage: 'conflict', message: 'row "x" is declared twice by gone' },
       },
     ].sort(byId))

+ 0 - 2
packages/test-support/loader-smoke/tests/fixtures/production-profile.ts

@@ -46,8 +46,6 @@ function overlayModuleLayers(path: string, patches: readonly PatchOptions[]): Pr
     version: undefined,
     packageDir,
     patchPath: path,
-    trust: 'builtin' as const,
-    stage: 'runtime' as const,
     patches: [],
   }))
 }

+ 0 - 1
packages/util/package-manifest/src/index.ts

@@ -4,7 +4,6 @@
  */
 
 export type {
-  BundleStage,
   DshBundleManifest,
   DshClientManifest,
   DshEnginesManifest,

+ 0 - 18
packages/util/package-manifest/src/types.ts

@@ -52,14 +52,6 @@ export interface DshEnginesManifest {
   [engine: string]: string | undefined
 }
 
-/**
- * Startup policy for an external bundle. `runtime` (the default) permits
- * failed rows while retaining successful siblings; conflicting row ids omit
- * the whole bundle. `boot` rows are required and claim ids with built-in layers.
- * This field does not delay execution until after startup.
- */
-export type BundleStage = 'boot' | 'runtime'
-
 /** One agent-plane module a package declares addable to a composition. */
 export interface DshPluginDeclaration {
   /** The package's export subpath, with `.` naming its main entry and `./tools` a subpath. */
@@ -74,8 +66,6 @@ export interface DshPluginDeclaration {
 export interface DshBundleManifest {
   /** Patch file path relative to the declaring package root. */
   patch: string
-  /** Mount stage the bundle author asks for; the profile's `stages` overrides it. */
-  stage?: BundleStage
 }
 
 /** The bundle composition declared by a profile directory. */
@@ -90,14 +80,6 @@ export interface DshProfileManifest {
   disabledBundles?: string[]
   /** User patch lifecycle; omitted means `live` for custom profiles. */
   patchReload?: ProfilePatchReload
-  /** Deployer overrides of each external bundle's mount stage, by package name. */
-  stages?: Record<string, BundleStage>
-  /**
-   * Installed packages treated as built-in: not wrapped and fatal on failure.
-   * For first-party packages linked into a profile during development, where
-   * provenance alone would classify them external.
-   */
-  firstParty?: string[]
 }
 
 /** Whether user patch files reload while a profile remains active or apply only at startup. */

+ 1 - 1
scripts/gen-doc-graphs.ts

@@ -412,7 +412,7 @@ const SERVICE_ROLES: ServiceRole[] = [
     title: 'Booted profile facts and recomposition',
     mode: 'core',
     consumers: ['host-plugin-inventory', 'host-plugin-manager'],
-    note: 'Provided by the profile launcher after boot: the composed bundle layers with their trust and stage, each row\'s inserting layer, the rows user patch files disable, and the one recomposition path user patch reloads and runtime bundle changes share.',
+    note: 'Provided by the profile launcher before entries mount: the composed bundle layers, each row\'s inserting layer, the rows user patch files disable, and the one recomposition path user patch reloads and runtime bundle changes share.',
   },
   {
     key: 'commands',