refactor(permission): project the seed boundary

This commit is contained in:
_Kerman 2026-08-27 16:25:43 +08:00
parent 42e0781cda
commit 1c2acd9157
19 changed files with 62 additions and 41 deletions

View file

@ -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

View file

@ -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.

View file

@ -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(热模块替换)不会遗留未固定的会话。无归属的「通用」设置包不贡献任何占位行。

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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/*`

View file

@ -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/*`

View file

@ -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

View file

@ -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 -->

View file

@ -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 -->

View file

@ -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

View file

@ -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

View file

@ -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 标记让该判断与旋钮值共用同一份增量状态。
### 可选子功能

View file

@ -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)

View file

@ -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

View file

@ -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)
})
})

View file

@ -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).