subagent: seed inherited policy events at creation

The parent implementation introduced sandboxMode and approvalPolicy as generic SessionHeader fields, then propagated those fields through both persistence backends, session-query indexes, collision checks, policy-specific seed-boundary folds, catalogs, and a broad test matrix. That storage plane is unnecessary: Session already accepts a validated constructor seed, and persistence captures that seed when the session is announced before committing its first batch.

Capture each parent override synchronously at delegation, append source-tagged sandbox/mode and approval/policy records after the optional fork prefix, and create the child with that combined seed. Keeping header.seedLength at the original fork-prefix length preserves lineage while ordinary last-event-wins folds make the inherited records outrank stale parent history and remain subordinate to later child switches. Unswitched parents still stamp nothing, so children continue to follow deployment defaults.

Remove the generic header fields and every persistence/query/schema branch built around them. Collapse the inheritance suite from ten leaking scenarios to four owned-context cases covering real filesystem confinement, stale fork precedence, delegation-time capture, and the no-override path. The assembled headless snapshot now asserts the persisted inheritance event directly.

This keeps the security behavior while restoring policy ownership to the existing event log and deleting the speculative durability machinery that the original tests did not exercise.
This commit is contained in:
Tianyi Cui
2026-07-28 21:31:17 +08:00
parent afa38c4b2f
commit cfceb8452b
55 changed files with 415 additions and 1371 deletions

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/session-persistence/session-persistence-jsonl/README.md
README.md: 2f68b3d02fecb61042964e69edbb446219a02e25
README.zh.md: 74a73d1c48ddc2ef0de4679489b8494b7364eee5
# pnpm run verify-translation-pairing --write
README.md: ab6ecd28f12bd167aeac789d1565705e167d60f4
README.zh.md: 97d387a04fa4c658217e28619410a49b7e6d4ec0

View File

@@ -14,7 +14,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
session.jsonl # only with compression: 'none'
```
- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth, sandboxMode?, approvalPolicy? }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. `sandboxMode`/`approvalPolicy` are the optional delegation-inheritance baselines, stored as neutral strings and validated by their policy owners on read. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`).
- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`).
- A storage record is a `SessionEvent` JSON verbatim, or — for an eligible run when `packChunks` is enabled — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically.
- The project directory keeps the normalized cwd readable for navigation and is bounded for filesystem component limits. Separator replacement and truncation are intentionally lossy, so cwd strings that normalize alike share a project directory; session ids still select distinct session directories. On a case-insensitive filesystem, identity validation accepts an alternate path spelling only when filesystem canonicalization resolves both spellings to the same transcript. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. The [project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) records this tradeoff.
- Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). The resulting directory is reserved for additional session-owned artifacts; discovery reads only the fixed transcript filename.

View File

@@ -14,7 +14,7 @@ JSONL 持久会话持久化后端:一个具体 `SessionPersistence`(`dsh-ses
session.jsonl # only with compression: 'none'
```
- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth, sandboxMode?, approvalPolicy? }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。`sandboxMode`/`approvalPolicy` 是可选的委派继承基线,以中性字符串存储,由各自的策略 owner 在读取时校验。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。
- 第一个逻辑行是不可变的 `SessionHeader`,标记为 `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`。`delegationDepth` 在磁盘上必需,顶层会话为 `0`;缺失或无效值会拒绝日志。后续每个逻辑行是一条存储记录;`assistant/chunk` 事件绝不丢弃,且 `seq` 在解码日志中保持连续(`events[i].seq === i`)。
- 存储记录是原样 `SessionEvent` JSON,或在 `packChunks` 已启用且连续段符合条件时写入的**打包分片行**(`text-chunks` / `reasoning-chunks` / `tool-call-chunks`;像 header 的 `session` 一样不带斜杠,因此行 tag 不会与事件类型混淆):一行保存至少 3 个连续同 block `assistant/chunk` delta 事件,`seq0`/`time0` 和每成员 `dt` 间隔精确重建每个成员的 `seq`/`time`。无损 codec 位于 `@deepseek-ai/dsh-session`(`packChunkRuns`/`decodeStorageRecord`),并使用精确形态 allowlist:任何未识别内容原样存储。读取与布局无关:`load` 始终解码行,因此打包、非打包和混合文件加载结果一致。
- 项目目录保留规范化 cwd 可读,并限制在文件系统组件上限内。分隔符替换和截断刻意有损,因此规范化相同的 cwd 字符串共享项目目录;会话 id 仍选择不同会话目录。在不区分大小写的文件系统上,只有文件系统规范化将两种写法解析到同一 transcript 时,身份验证才接受备选路径写法。配置根仍由部署控制:可以是项目本地、共享、临时或集中式。[项目会话目录决策](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) 记录这项取舍。
- 会话 id 是未验证的品牌化字符串,因此在使用前单射转义为一个安全路径段(无遍历、无冲突)。结果目录保留给其他会话自有产物;发现只读取固定 transcript 文件名。

View File

@@ -38,8 +38,6 @@ export interface HeaderLine {
parentSession?: SessionId
seedLength?: number
delegationDepth: number
sandboxMode?: string
approvalPolicy?: string
}
/**
@@ -57,8 +55,6 @@ export function toHeaderLine(header: SessionHeader): HeaderLine {
...header.parentSession !== undefined ? { parentSession: header.parentSession } : {},
...header.seedLength !== undefined ? { seedLength: header.seedLength } : {},
delegationDepth: header.delegationDepth ?? 0,
...header.sandboxMode !== undefined ? { sandboxMode: header.sandboxMode } : {},
...header.approvalPolicy !== undefined ? { approvalPolicy: header.approvalPolicy } : {},
}
}
@@ -76,8 +72,6 @@ export function fromHeaderLine(line: HeaderLine): SessionHeader {
...line.parentSession !== undefined ? { parentSession: line.parentSession } : {},
...line.seedLength !== undefined ? { seedLength: line.seedLength } : {},
delegationDepth: line.delegationDepth,
...line.sandboxMode !== undefined ? { sandboxMode: line.sandboxMode } : {},
...line.approvalPolicy !== undefined ? { approvalPolicy: line.approvalPolicy } : {},
}
}
@@ -96,10 +90,6 @@ function isHeaderLine(value: unknown): value is HeaderLine {
&& Number.isSafeInteger((value as { delegationDepth: number }).delegationDepth)
&& (value as { delegationDepth: number }).delegationDepth >= 0
&& !Object.is((value as { delegationDepth: number }).delegationDepth, -0)
&& ((value as { sandboxMode?: unknown }).sandboxMode === undefined
|| typeof (value as { sandboxMode?: unknown }).sandboxMode === 'string')
&& ((value as { approvalPolicy?: unknown }).approvalPolicy === undefined
|| typeof (value as { approvalPolicy?: unknown }).approvalPolicy === 'string')
)
}

View File

@@ -301,17 +301,15 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
private writeRow(meta: SessionHeader): void {
this.db.prepare(`
INSERT INTO sessions
(id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision, sandbox_mode, approval_policy)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
(id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
ON CONFLICT(id) DO UPDATE SET
version = excluded.version,
created_at = excluded.created_at,
cwd = excluded.cwd,
parent_session = excluded.parent_session,
seed_length = excluded.seed_length,
delegation_depth = excluded.delegation_depth,
sandbox_mode = excluded.sandbox_mode,
approval_policy = excluded.approval_policy
delegation_depth = excluded.delegation_depth
`).run(
meta.id,
meta.version,
@@ -321,8 +319,6 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
meta.seedLength ?? null,
meta.delegationDepth ?? null,
randomUUID(),
meta.sandboxMode ?? null,
meta.approvalPolicy ?? null,
)
}
}

View File

@@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee
* layout; orthogonal to a session's own `version` (which versions the EVENT
* vocabulary, stored per session in the `sessions` row).
*/
export const SCHEMA_VERSION = 11
export const SCHEMA_VERSION = 10
/** SQLite application id protecting unrelated databases from persistence writes. */
export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850
@@ -41,10 +41,6 @@ export interface SessionRow {
/** Monotonic log-change token incremented in each mutating transaction. */
revision: number
delegation_depth: number | null
/** The inherited sandbox-mode delegation baseline, or NULL. */
sandbox_mode: string | null
/** The inherited approval-policy delegation baseline, or NULL. */
approval_policy: string | null
}
/** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */
@@ -128,9 +124,7 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
seed_length INTEGER,
delegation_depth INTEGER,
incarnation TEXT NOT NULL,
revision INTEGER NOT NULL,
sandbox_mode TEXT,
approval_policy TEXT
revision INTEGER NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS events (
@@ -187,8 +181,6 @@ export function rowToMeta(row: SessionRow): SessionHeader {
...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {},
...row.seed_length !== null ? { seedLength: row.seed_length } : {},
...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {},
...row.sandbox_mode !== null ? { sandboxMode: row.sandbox_mode } : {},
...row.approval_policy !== null ? { approvalPolicy: row.approval_policy } : {},
}
}

View File

@@ -171,8 +171,6 @@ describe('rowToMeta', () => {
incarnation: 'fractional',
revision: 1,
delegation_depth: null,
sandbox_mode: null,
approval_policy: null,
})).toThrow('stored session createdAt must be a non-negative safe integer')
})
})
@@ -611,7 +609,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
})
it('exposes the schema version constant', () => {
expect(SCHEMA_VERSION).toBe(11)
expect(SCHEMA_VERSION).toBe(10)
})
it('keeps the revision stable for an empty repair hook', async () => {

View File

@@ -118,32 +118,6 @@ async function settledErrors(promises: Iterable<Promise<unknown>>): Promise<unkn
return errors
}
/**
* Reject a stored/live pair whose immutable policy baselines differ.
* Adoption and ownerless claims retain the STORED header, so accepting a
* conflicting pair would let a session run under its live baseline now but
* resume under the stored one later — a silent policy swap. When either side
* carries a baseline, the seed boundary is part of the policy identity too:
* `overrideOf()` folds own switches past `seedLength`, so differing
* boundaries make the same log resolve different policies across a restart.
*/
function assertSamePolicyBaselines(id: SessionId, stored: SessionHeader, live: SessionHeader): void {
if (stored.sandboxMode !== live.sandboxMode || stored.approvalPolicy !== live.approvalPolicy) {
throw new Error(
`session "${id}" is already persisted with a different policy baseline `
+ `(persisted: ${String(stored.sandboxMode)}/${String(stored.approvalPolicy)}, `
+ `live: ${String(live.sandboxMode)}/${String(live.approvalPolicy)}) (id collision)`,
)
}
const hasBaseline = stored.sandboxMode !== undefined || stored.approvalPolicy !== undefined
if (hasBaseline && (stored.seedLength ?? 0) !== (live.seedLength ?? 0)) {
throw new Error(
`session "${id}" is already persisted with a different policy seed boundary `
+ `(persisted seedLength: ${String(stored.seedLength)}, live: ${String(live.seedLength)}) (id collision)`,
)
}
}
/** Whether a live session seed reproduces a persisted prefix exactly. */
function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly SessionEvent[]): boolean {
return prefix.length <= seed.length
@@ -586,7 +560,6 @@ export class PersistenceCoordinator<TornMarker = unknown> {
if (tracked.meta.cwd !== session.header.cwd) {
throw new Error(`session "${id}" is already persisted at a different cwd (persisted: ${String(tracked.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`)
}
assertSamePolicyBaselines(id, tracked.meta, session.header)
if (!await this.seedMatchesPersisted(id, seed, tracked.cursor)) {
throw new Error(`session "${id}" is already persisted with ${tracked.cursor} event(s) that do not match this live session (id collision)`)
}
@@ -640,7 +613,6 @@ export class PersistenceCoordinator<TornMarker = unknown> {
if (meta.cwd !== session.header.cwd) {
throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`)
}
assertSamePolicyBaselines(session.header.id, meta, session.header)
this.assertVersion(meta)
assertSupportedEvents(events, session.header.id)
if (!seedCoversPrefix(seed, events)) {

View File

@@ -99,36 +99,6 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
}
})
it('round-trips the inherited policy baselines exactly and omits them when absent', async () => {
const { persistence, dispose } = await make()
try {
// A delegated child header: the sandbox/approval baselines must
// survive storage verbatim — a resumed child falling back to the
// deployment default would reopen the delegation bypass.
const child: SessionHeader = {
...meta('s-baseline', '/work'),
delegationDepth: 1,
sandboxMode: 'read-only',
approvalPolicy: 'never',
}
await persistence.create(child)
await persistence.append(child.id, oneTurnLog())
const loaded = await persistence.load(child.id)
expect(loaded.meta).toMatchObject({ sandboxMode: 'read-only', approvalPolicy: 'never' })
// A top-level header: absent baselines stay ABSENT (not null/empty) —
// presence is the signal the policy owners branch on.
const top = meta('s-no-baseline', '/work')
await persistence.create(top)
await persistence.append(top.id, oneTurnLog())
const reloaded = await persistence.load(top.id)
expect('sandboxMode' in reloaded.meta).toBe(false)
expect('approvalPolicy' in reloaded.meta).toBe(false)
} finally {
await dispose()
}
})
it('rejects a fractional creation timestamp without reserving its session id', async () => {
const { persistence, dispose } = await make()
try {
@@ -307,22 +277,6 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
}
})
it('resolves an observation read normally when its signal never aborts', async () => {
const { persistence, dispose } = await make()
try {
// The abort observer must not swallow an ordinary success: a signal
// that stays quiet leaves the queued operation's resolution intact.
const m = meta('signal-quiet-inspect', '/work')
await persistence.create(m)
await persistence.append(m.id, oneTurnLog())
const controller = new AbortController()
await expect(persistence.inspect(m.id, controller.signal))
.resolves.toMatchObject({ meta: { id: m.id } })
} finally {
await dispose()
}
})
it('rejects pre-aborted observation reads with the exact cancellation reason', async () => {
const { persistence, dispose } = await make()
try {

View File

@@ -753,75 +753,6 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
}
})
it('a live session with a CONFLICTING policy baseline cannot adopt a stored prefix', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
try {
// A stored artifact carrying a WIDE baseline. A same-id live session
// claiming a NARROW baseline must be rejected: adoption retains the
// stored header, so accepting the pair would let the session append
// under read-only now but resume under danger-full-access later.
await ctx.sessionPersistence.create({ ...meta('baseline-conflict', WORK), sandboxMode: 'danger-full-access' })
await ctx.sessionPersistence.append(SessionId('baseline-conflict'), oneTurnLog())
const live = ctx.sessions.create(SessionId('baseline-conflict'), {
seed: oneTurnLog(),
meta: { cwd: WORK, sandboxMode: 'read-only' },
})
await expect(ctx.sessions.flush(live)).rejects.toThrow(/policy baseline|id collision/)
} finally {
await fiber.dispose()
await fix.cleanup()
}
})
it('a live session with a DIFFERENT seed boundary cannot adopt a stored prefix when a baseline exists', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
try {
// Same wide baseline both sides, but the stored header says event 0 is
// seed-carried (seedLength 1) while the live header says it is the
// session's OWN (seedLength 0). overrideOf() resolves policy through
// that boundary: a read-only switch at event 0 tightens the live
// session, yet a restart resumes under the stored header and the wide
// baseline silently returns. The boundary is part of the policy
// identity whenever a baseline exists.
await ctx.sessionPersistence.create({
...meta('seed-boundary-conflict', WORK),
sandboxMode: 'danger-full-access',
seedLength: 1,
})
await ctx.sessionPersistence.append(SessionId('seed-boundary-conflict'), oneTurnLog())
const live = ctx.sessions.create(SessionId('seed-boundary-conflict'), {
seed: oneTurnLog(),
meta: { cwd: WORK, sandboxMode: 'danger-full-access' },
})
await expect(ctx.sessions.flush(live)).rejects.toThrow(/seed boundary|id collision/)
} finally {
await fiber.dispose()
await fix.cleanup()
}
})
it('an approval-only baseline also pins the seed boundary (the other baseline arm)', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
try {
// The boundary guard triggers off EITHER baseline: a stored header
// with only approvalPolicy (no sandboxMode, no seedLength) must still
// reject a live twin whose boundary differs.
await ctx.sessionPersistence.create({ ...meta('approval-boundary-conflict', WORK), approvalPolicy: 'never' })
await ctx.sessionPersistence.append(SessionId('approval-boundary-conflict'), oneTurnLog())
const live = ctx.sessions.create(SessionId('approval-boundary-conflict'), {
seed: oneTurnLog(),
meta: { cwd: WORK, approvalPolicy: 'never', seedLength: 2 },
})
await expect(ctx.sessions.flush(live)).rejects.toThrow(/seed boundary|id collision/)
} finally {
await fiber.dispose()
await fix.cleanup()
}
})
it('a no-cwd ownerless state cannot be claimed by a live session WITH a cwd (cwd scope, undefined side)', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)