Jelajahi Sumber

refactor(permission): project the seed boundary

_Kerman 2 minggu lalu
induk
melakukan
1c2acd9157

+ 2 - 2
.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.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/feature/2026-07-31-permission-default-for-new-sessions.md
-2026-07-31-permission-default-for-new-sessions.md: ebf7fe39712d64c18e12b9b26d86201a61ad6cfd
-2026-07-31-permission-default-for-new-sessions.zh.md: c0f450c8efca8647e3058fb305724cf0a554cc8d
+2026-07-31-permission-default-for-new-sessions.md: c4e223e61b69e249fefb97d89de5a4b49f4af403
+2026-07-31-permission-default-for-new-sessions.zh.md: ee3534411516498d30eb52ba5b25d1c1693a1d10

+ 1 - 1
.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md

@@ -12,7 +12,7 @@ The Web General-settings page displayed Permission as a disabled skeleton even t
 
 `dsh-permission-presets` owns a `permission` Settings namespace with one `defaultPreset` field. Its base value is `Config.defaultPreset`, or the preset matching the composed sandbox and approval defaults when the config omits it. The schema derives its enum from the configured preset table, so Settings validates stored values and the Web client discovers the deployment's actual choices without duplicating them.
 
-The service reads the current Settings value synchronously at `session/created`. A genuinely fresh session receives three explicit events: `permission/preset`, `sandbox/mode`, and `approval/policy`. Those facts pin the permission selected at creation, so a later Settings change affects only later sessions. A seeded or partially initialized session preserves its effective knobs and receives only missing facts; it never adopts the latest user default while resuming. `Session` marks even an explicitly empty constructor seed with `session/end-seed`, so an empty persisted log cannot be mistaken for a fresh session.
+The service reads the current Settings value synchronously at `session/created`. A genuinely fresh session receives three explicit events: `permission/preset`, `sandbox/mode`, and `approval/policy`. Those facts pin the permission selected at creation, so a later Settings change affects only later sessions. A seeded or partially initialized session preserves its effective knobs and receives only missing facts; it never adopts the latest user default while resuming. `Session` marks even an explicitly empty constructor seed with `session/end-seed`, so an empty persisted log cannot be mistaken for a fresh session. The `permissions` projection folds that boundary with the three permission facts, letting session pinning read one incremental state instead of rescanning the log.
 
 The existing `/permission` command and `permissions` projection remain the current-session path. The browser plugin now contributes the Permission row to `settings.general.item`, reads the dynamic enum from the redacted Settings descriptor, and writes only `defaultPreset` through a revision-checked `settings.mutate`. The row injects its observable through the slot `hooks` compartment instead of binding a renderer-specific hook, and the Permission service sweeps already-live sessions when it mounts so HMR cannot leave an unpinned session. The ownerless General-settings package contributes no placeholder rows.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md

@@ -12,7 +12,7 @@ Web「通用」设置页将「权限」显示为禁用的骨架控件,尽管 `
 
 `dsh-permission-presets` 拥有一个 `permission` Settings namespace,其中只有 `defaultPreset` 字段。它的基础值是 `Config.defaultPreset`;省略该配置时,则使用与组合后的沙箱和审批默认值匹配的 preset。schema 的 enum 从已配置的 preset 表派生,因此 Settings 既能校验已存储的值,Web 客户端也能发现部署中的实际选项,而无需重复定义。
 
-服务会在 `session/created` 时同步读取当前 Settings 值。真正的新会话会收到三个显式事件:`permission/preset`、`sandbox/mode` 和 `approval/policy`。这些事实将创建时选中的权限固定下来,因此后续 Settings 变更只影响之后的会话。带 seed 或只完成部分初始化的会话会保留其有效调节项,只补齐缺失的事实;恢复时绝不会采用最新的用户默认值。`Session` 甚至会用 `session/end-seed` 标记显式为空的构造器 seed,因此不能把空的持久化日志误认为新会话。
+服务会在 `session/created` 时同步读取当前 Settings 值。真正的新会话会收到三个显式事件:`permission/preset`、`sandbox/mode` 和 `approval/policy`。这些事实将创建时选中的权限固定下来,因此后续 Settings 变更只影响之后的会话。带 seed 或只完成部分初始化的会话会保留其有效调节项,只补齐缺失的事实;恢复时绝不会采用最新的用户默认值。`Session` 甚至会用 `session/end-seed` 标记显式为空的构造器 seed,因此不能把空的持久化日志误认为新会话。`permissions` 投影把该边界与三个权限事实一同折叠,使会话固定只读一份增量状态,无需重扫日志。
 
 现有 `/permission` 命令和 `permissions` 投影仍是当前会话的操作路径。浏览器插件现在向 `settings.general.item` 贡献「权限」行,从脱敏后的 Settings 描述符读取动态 enum,并只通过经过 revision 校验的 `settings.mutate` 写入 `defaultPreset`。该行通过 slot 的 `hooks` 格注入 observable,而不是绑定渲染器专用钩子;权限服务挂载时会遍历并固定所有已存活会话,因此 HMR(热模块替换)不会遗留未固定的会话。无归属的「通用」设置包不贡献任何占位行。
 

+ 2 - 2
docs/config-catalog.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/config-catalog.md
-config-catalog.md: 20525386ae8cf069e7b63498e045c03e657762e0
-config-catalog.zh.md: f9618f66d4df592b405ebcb3aa601acdabc9625e
+config-catalog.md: b7cc692acf34095ee5480c0fa644998b24c8d878
+config-catalog.zh.md: e49267c79d59c4cc19ff110350836c0ad0c8529a

+ 1 - 1
docs/config-catalog.md

@@ -1492,7 +1492,7 @@ export interface PresetSpec {
 
 Depends on: [`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsystems/sandbox.md)
 
-Source: [`packages/interaction/permission-presets/src/index.ts:112`](../packages/interaction/permission-presets/src/index.ts)
+Source: [`packages/interaction/permission-presets/src/index.ts:134`](../packages/interaction/permission-presets/src/index.ts)
 
 <a id="deepseek-aidsh-persona"></a>
 

+ 1 - 1
docs/config-catalog.zh.md

@@ -1494,7 +1494,7 @@ export interface PresetSpec {
 
 依赖:[`ApprovalPolicy`](subsystems/approval.zh.md) · [`SandboxMode`](subsystems/sandbox.zh.md)
 
-来源:[`packages/interaction/permission-presets/src/index.ts:112`](../packages/interaction/permission-presets/src/index.ts)
+来源:[`packages/interaction/permission-presets/src/index.ts:134`](../packages/interaction/permission-presets/src/index.ts)
 
 <a id="deepseek-aidsh-persona"></a>
 

+ 2 - 2
docs/persistence-catalog.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/persistence-catalog.md
-persistence-catalog.md: 01b768c2368039cc503920fe438d39a5ff1669c3
-persistence-catalog.zh.md: 93498359c7b945bd1bb024b1ddb49cefabafaa63
+persistence-catalog.md: 6a0650c1e4b73e68038259cde6ecf919a3cf02ee
+persistence-catalog.zh.md: cca6f2353aa02876a890665ab6badc3c6038493e

+ 1 - 1
docs/persistence-catalog.md

@@ -519,7 +519,7 @@ Source: [`packages/api/session-controller/src/types.ts:40`](../packages/api/sess
 'permission/preset': { preset: string }
 ```
 
-Source: [`packages/interaction/permission-presets/src/index.ts:46`](../packages/interaction/permission-presets/src/index.ts)
+Source: [`packages/interaction/permission-presets/src/index.ts:59`](../packages/interaction/permission-presets/src/index.ts)
 
 ### `plan/*`
 

+ 1 - 1
docs/persistence-catalog.zh.md

@@ -521,7 +521,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 'permission/preset': { preset: string }
 ```
 
-来源:[`packages/interaction/permission-presets/src/index.ts:46`](../packages/interaction/permission-presets/src/index.ts)
+来源:[`packages/interaction/permission-presets/src/index.ts:59`](../packages/interaction/permission-presets/src/index.ts)
 
 ### `plan/*`
 

+ 2 - 2
docs/subsystems/permission-presets.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/permission-presets.md
-permission-presets.md: 13ccf26da0948ae8905bfe4917d351a35861ab63
-permission-presets.zh.md: d6d50eedb067d683f0bfb7504332cb00aa5ca4f1
+permission-presets.md: f4c3fafb9eea79f58255f609affaf906c24c3bcd
+permission-presets.zh.md: 7dd2927ad2d17c1715ad13085c791a73b5963c6a

+ 1 - 1
docs/subsystems/permission-presets.md

@@ -65,7 +65,7 @@ interface PresetOption {
 
 `set(session, name)` resolves the preset (unknown names throw), appends a log-only `permission/preset` event unless `name` is already the effective preset, then writes each knob through its own setter — `setSandboxMode` from [dsh-sandbox-policy](../../packages/sandbox/sandbox-policy) and `setApprovalPolicy` from [dsh-user-approval](../../packages/interaction/user-approval) — only when that knob's effective value changes. The selection event precedes the knob events in the same turn, and re-selecting the effective preset appends nothing.
 
-`permission/preset` is durable, log-only user intent: it stays out of the model transcript (the knob events own the model-visible consequences through their consumers), and it exists so `current()` can preserve WHICH preset the user chose when two presets share a bundle. The `permissions` projection folds that selection with both knob events; replay needs no catch-up state. The complete event declaration is in the [persistence log event catalog](../persistence-catalog.md); the method signatures are in the generated [service catalog](#ctxpermissionpresets--permissionpresetservice).
+`permission/preset` is durable, log-only user intent: it stays out of the model transcript (the knob events own the model-visible consequences through their consumers), and it exists so `current()` can preserve WHICH preset the user chose when two presets share a bundle. The `permissions` projection folds that selection with both knob events and retains the `session/end-seed` boundary used to distinguish a restored empty seed from a fresh session; replay needs no catch-up state or raw-log rescan. The complete event declaration is in the [persistence log event catalog](../persistence-catalog.md); the method signatures are in the generated [service catalog](#ctxpermissionpresets--permissionpresetservice).
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 

+ 1 - 1
docs/subsystems/permission-presets.zh.md

@@ -65,7 +65,7 @@ interface PresetOption {
 
 `set(session, name)` 解析预设(未知名称抛出异常),在 `name` 尚不是生效预设时追加一条仅记日志的 `permission/preset` 事件,然后通过各旋钮自己的 setter([dsh-sandbox-policy](../../packages/sandbox/sandbox-policy) 的 `setSandboxMode` 与 [dsh-user-approval](../../packages/interaction/user-approval) 的 `setApprovalPolicy`)写入,且仅当该 knob的生效值发生变化时才写。同一轮次内,选择事件先于旋钮事件出现;重新选择当前生效的预设则什么都不追加。
 
-`permission/preset` 是持久、仅记日志的用户意图:它不进入模型 transcript(文本记录),模型可见的后果由 knob 事件经各自消费方承担;它存在是为了在两个预设共享同一个旋钮组合时,让 `current()` 仍能保住用户选择的究竟是哪一个预设。`permissions` 投影把该选择与两个 knob 事件一同折叠,回放不需要任何追赶状态。完整事件声明见[持久化日志事件目录](../persistence-catalog.zh.md);方法签名见生成的[服务目录](#ctxpermissionpresets--permissionpresetservice)。
+`permission/preset` 是持久、仅记日志的用户意图:它不进入模型 transcript(文本记录),模型可见的后果由 knob 事件经各自消费方承担;它存在是为了在两个预设共享同一个旋钮组合时,让 `current()` 仍能保住用户选择的究竟是哪一个预设。`permissions` 投影把该选择与两个 knob 事件一同折叠,并保留用于区分空恢复 seed 与新会话的 `session/end-seed` 边界;回放不需要任何追赶状态或原始日志重扫。完整事件声明见[持久化日志事件目录](../persistence-catalog.zh.md);方法签名见生成的[服务目录](#ctxpermissionpresets--permissionpresetservice)。
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 

+ 2 - 2
packages/interaction/permission-presets/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/interaction/permission-presets/README.md
-README.md: e331a4d56b766296043c70da957136123f62453a
-README.zh.md: 44830382e24ce4c1db804dee423b0ad4fcb7f013
+README.md: fa2ea0f96c7dc3cd7b31932942e2eaba85e656b6
+README.zh.md: 76bd299214e8650a3a46ebd4e1504c1131fba678

+ 2 - 2
packages/interaction/permission-presets/README.md

@@ -87,11 +87,11 @@ The observable behavior is covered in [Use this package](#use-this-package); thi
 
 ### Read side and `custom`
 
-`current(session)` reads the `permissions` projection, whose unit folds the three whole-value knob events over the composition defaults (`ctx.shell.sandboxMode` and the approval config). A still-matching last selection wins shared-bundle ties; otherwise the first table match wins; otherwise the derived `CUSTOM_PRESET` is returned. A missing registry or projection key fails explicitly.
+`current(session)` reads the `permissions` projection, whose unit folds the three whole-value knob events over the composition defaults (`ctx.shell.sandboxMode` and the approval config). The host state also retains whether `session/end-seed` has occurred, so session pinning distinguishes an explicitly empty restored seed from a genuinely fresh session without rescanning the log. A still-matching last selection wins shared-bundle ties; otherwise the first table match wins; otherwise the derived `CUSTOM_PRESET` is returned. A missing registry or projection key fails explicitly.
 
 ### Session pinning and blank reuse
 
-Mounting pins every live and future session: a genuinely fresh session gains the default preset and both knob facts, while seeded or partially initialized sessions keep their effective knob values and gain only missing durable facts.
+Mounting pins every live and future session: a genuinely fresh session gains the default preset and both knob facts, while seeded or partially initialized sessions keep their effective knob values and gain only missing durable facts. The projection-owned seed marker makes this decision from the same incremental state as the knob values.
 
 ### Optional children
 

+ 2 - 2
packages/interaction/permission-presets/README.zh.md

@@ -87,11 +87,11 @@ kind: "package-reference"
 
 ### 读取侧与 `custom`
 
-`current(session)` 读取 `permissions` 投影;该单元在组合默认值(`ctx.shell.sandboxMode` 与审批配置)之上折叠三个全量值旋钮事件。仍匹配的最近选择在共享捆绑时胜出;否则表中第一个匹配项胜出;否则返回推导出的 `CUSTOM_PRESET`。注册表或投影 key 缺失时会显式失败。
+`current(session)` 读取 `permissions` 投影;该单元在组合默认值(`ctx.shell.sandboxMode` 与审批配置)之上折叠三个全量值旋钮事件。host 状态还会保留 `session/end-seed` 是否已经出现,使会话固定无需重扫日志即可区分显式为空的恢复 seed 与真正的新会话。仍匹配的最近选择在共享捆绑时胜出;否则表中第一个匹配项胜出;否则返回推导出的 `CUSTOM_PRESET`。注册表或投影 key 缺失时会显式失败。
 
 ### 会话固定与空白复用
 
-挂载时会固定所有存活与未来的会话:真正全新的会话获得默认预设与两个旋钮事实,而 seed 会话或部分初始化的会话保留其有效旋钮值,只补充缺失的持久事实。
+挂载时会固定所有存活与未来的会话:真正全新的会话获得默认预设与两个旋钮事实,而 seed 会话或部分初始化的会话保留其有效旋钮值,只补充缺失的持久事实。投影自有的 seed 标记让该判断与旋钮值共用同一份增量状态。
 
 ### 可选子功能
 

+ 35 - 13
packages/interaction/permission-presets/src/index.ts

@@ -35,6 +35,19 @@ declare module '@deepseek-ai/cordis' {
   }
 }
 
+/** Projection state for permission overrides and constructor-seed provenance. */
+interface PermissionProjectionState extends KnobState {
+  /** Whether the log contains a constructor-seed boundary. */
+  seeded: boolean
+}
+
+declare module '@deepseek-ai/dsh-session-projection/types' {
+  interface SessionProjectionStateMap {
+    /** Latest logged permission overrides and constructor-seed provenance. */
+    permissions: PermissionProjectionState
+  }
+}
+
 declare module '@deepseek-ai/dsh-session/types' {
   interface SessionEventMap {
     /**
@@ -68,7 +81,7 @@ export const CUSTOM_PRESET = 'custom'
 /** Settings namespace carrying the default for future sessions. */
 export const PERMISSION_SETTINGS_NAMESPACE = settingsNamespace('permission')
 
-const knobStateSchema: zod.ZodType<KnobState> = zod.object({
+const permissionStateSchema: zod.ZodType<PermissionProjectionState> = zod.object({
   /** Last `permission/preset` payload, or null. */
   preset: zod.string().nullable(),
   sandbox: zod.union([
@@ -77,19 +90,26 @@ const knobStateSchema: zod.ZodType<KnobState> = zod.object({
     zod.literal('danger-full-access'),
   ]).nullable(),
   approval: zod.union([zod.literal('ask'), zod.literal('never')]).nullable(),
+  seeded: zod.boolean(),
 }).strict()
 
 /** State for the empty log: every knob at its composition default. */
 const EMPTY_KNOBS: KnobState = { preset: null, sandbox: null, approval: null }
 
+/** Projection state for a genuinely fresh session. */
+const EMPTY_PERMISSION_STATE: PermissionProjectionState = { ...EMPTY_KNOBS, seeded: false }
+
 /**
- * One-event knob transition (the projection unit's `apply`). Uninterested
+ * One-event permission-state transition (the projection unit's `apply`). Unrelated
  * events return the same reference — the registry's change gate.
  * @param state - the folded knob state before `event`.
  * @param event - one committed session event.
- * @returns the next state; the same reference when the event is not a knob.
+ * @returns the next state; the same reference when the event is unrelated.
  */
-export function applyKnobEvent(state: KnobState, event: SessionEvent): KnobState {
+function applyPermissionEvent(
+  state: PermissionProjectionState,
+  event: SessionEvent,
+): PermissionProjectionState {
   switch (event.type) {
     case 'permission/preset':
       return { ...state, preset: event.data.preset }
@@ -97,6 +117,8 @@ export function applyKnobEvent(state: KnobState, event: SessionEvent): KnobState
       return { ...state, sandbox: event.data.mode }
     case 'approval/policy':
       return { ...state, approval: event.data.policy }
+    case 'session/end-seed':
+      return state.seeded ? state : { ...state, seeded: true }
     default:
       return state
   }
@@ -206,10 +228,10 @@ export class PermissionPresetService extends Service {
     ctx.inject(['sessionProjections'], (projectionCtx) => {
       projectionCtx.sessionProjections.register({
         key: 'permissions',
-        stateVersion: 1,
-        stateSchema: knobStateSchema,
-        init: () => EMPTY_KNOBS,
-        apply: applyKnobEvent,
+        stateVersion: 2,
+        stateSchema: permissionStateSchema,
+        init: () => EMPTY_PERMISSION_STATE,
+        apply: applyPermissionEvent,
         wire: { viewSchema: selectSchema, view: state => this.selectFor(state) },
       })
       projectionCtx.on('session/created', (session) => {
@@ -263,7 +285,7 @@ export class PermissionPresetService extends Service {
     return this.defaultSettings().defaultPreset
   }
 
-  private knobs(session: Session): KnobState {
+  private permissionState(session: Session): PermissionProjectionState {
     const projections = this.ctx.get('sessionProjections')
     if (projections === undefined) throw new Error('permission: session projection registry is unavailable')
     const state = projections.stateOf(session, 'permissions')
@@ -279,7 +301,7 @@ export class PermissionPresetService extends Service {
    * @returns the effective preset name, or `custom` when nothing matches.
    */
   current(session: Session): string {
-    return this.derive(this.knobs(session))
+    return this.derive(this.permissionState(session))
   }
 
   /** Resolve the preset for one folded knob state (the shared mathematics of `current` and the projection unit). */
@@ -359,7 +381,7 @@ export class PermissionPresetService extends Service {
     if (this.current(session) !== name) {
       session.append('permission/preset', { preset: name })
     }
-    const knobs = this.knobs(session)
+    const knobs = this.permissionState(session)
     if (spec.sandbox !== (knobs.sandbox ?? this.ctx.shell.sandboxMode)) {
       setSandboxMode(session, spec.sandbox)
     }
@@ -375,11 +397,11 @@ export class PermissionPresetService extends Service {
    * the missing durable facts.
    */
   private pinInitialPermission(session: Session): void {
-    const state = this.knobs(session)
+    const state = this.permissionState(session)
     const selected = state.preset
     const sandbox = state.sandbox
     const approval = state.approval
-    const seeded = session.events.some(event => event.type === 'session/end-seed')
+    const seeded = state.seeded
     if (selected === null && sandbox === null && approval === null && !seeded) {
       const name = this.defaultPreset
       const spec = this.resolve(name)

+ 0 - 4
packages/interaction/permission-presets/src/types.ts

@@ -44,10 +44,6 @@ export interface KnobState {
 }
 
 declare module '@deepseek-ai/dsh-session-projection/types' {
-  interface SessionProjectionStateMap {
-    /** Latest logged permission overrides. */
-    permissions: KnobState
-  }
   interface SessionProjectionMap {
     /**
      * The session's permission select, folded from the three whole-value

+ 3 - 0
packages/interaction/permission-presets/tests/permission-presets.spec.ts

@@ -80,6 +80,9 @@ describe('permission preset fold', () => {
     // The knob fold steps over non-preset events to the latest selection.
     session.append('sandbox/mode', { mode: 'read-only' })
     expect(presetOf()).toBe('workspace-write')
+
+    const seeded = Session.create(SessionId('sess-fold-seeded'), [])
+    expect(ctx.sessionProjections.stateOf(seeded, 'permissions')?.seeded).toBe(true)
   })
 })
 

+ 2 - 2
packages/interaction/permission-presets/tests/projection.spec.ts

@@ -2,8 +2,8 @@
  * The `permissions` projection unit and the `/permission` command: mounting
  * the permission service beside the projection registry serves the whole
  * select (table options + effective current value, `custom` appended exactly
- * while derived) folded from the three knob events over the composition
- * defaults; the command child registers `/permission` whose handler switches
+ * while derived) folded from permission events over the composition defaults;
+ * the command child registers `/permission` whose handler switches
  * through `permission.set` (bare invocation reports, unknown names error);
  * compositions without either registry are unaffected; unmounting the
  * service removes the key (HMR safety).