fix(session): reject unknown required V3 events before target interpretation
The physical V3 codec admits an unclassified event envelope without deciding whether its type is required. The V3-to-V4 stage then used shared message walkers and target vocabulary, so an invalid required developer/message could acquire the V4 developer role although historical V3 restoration rejects it. Generic required unknown events were also left to the wrong version's reader. After preserving explicitly ignorable extensions, require every remaining source type to belong to the fixed V3 vocabulary before interpreting payloads. This is a fail-loud check for invalid or nonconforming source data, not new compatibility handling for third-party events, which carry ignorable:true. The approved plugin identifier conversion and native V4 readers are unchanged. Exercise a complete developer-event sequence through direct migration, historical/current catalogs, ignorable conversion and native V4 restoration; add a generic unknown type. Both negative controls accepted the invalid source before this guard. Plain/zstd read/write regressions verify refusal preserves the source generation and never publishes a partial V4 successor.
This commit is contained in:
1 parent
fc74dbf4ea
commit
11ef33b336
7 files changed
+60
-6
No files matched your search
@@ -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/session/session-format-v3-to-v4/README.md
|
||||
README.md: 703a7be2c57f728f84ba5bb36ab2e16cb1a90e0c
|
||||
README.zh.md: 709b5ecd476a19e2c8971db7546371d328fb0811
|
||||
README.md: fdb6c580f91dc89a73555289c6ddaa911afac414
|
||||
README.zh.md: eb25fea52b923915c8e73e2f93e9f57c8afa847f
|
||||
@@ -180,7 +180,7 @@ No delivery payload or event type is rewritten. Higher-version migrations own an
|
||||
|
||||
V3 physical decoding and header validation run before the stage. The stage checks dense sequences, source cuts, canonical tool-result wrappers, named plugin sources, delivery ownership, and supplied catalog evidence as specified above. It does not run the complete released V3 semantic restorer or copy the V2→V3 event/content allowlist. Complete restoration additionally applies the V4 target rules below; physical parsing, stage conversion, and target restoration are distinct checks.
|
||||
|
||||
Only the enumerated messages and fields are converted. Unrelated events and arbitrary JSON do not acquire new meanings from matching strings or numbers. The fixed `RELEASED_V3_EVENT_TYPES` set separates source events from extensions independently of the current writer. Unknown required events are invalid V3 input; unknown ignorable names become `plugin:<original-name>` with their payload unchanged. No generic source-schema validation or recursive numeric-field inference is implied.
|
||||
Only the enumerated messages and fields are converted. Unrelated events and arbitrary JSON do not acquire new meanings from matching strings or numbers. The fixed `RELEASED_V3_EVENT_TYPES` set separates source events from extensions independently of the current writer. The stage rejects unknown required V3 event types before interpreting their payloads or consulting the V4 vocabulary, including names that V4 recognizes. Unknown ignorable names become `plugin:<original-name>` with their payload unchanged. No generic source-schema validation or recursive numeric-field inference is implied.
|
||||
|
||||
Only the canonical V3 `tool/result` wrapper has a preserving conversion. A retired `tool-result` block in another interpreted position is refused by target admission rather than retained as an invalid V4 block. Native V4 applies the same tag refusal before recoverable suffix suppression. The check covers only these positions:
|
||||
|
||||
|
||||
@@ -180,7 +180,7 @@ Stage 只把最终继承截点之后的父目录记录作为候选。每个 inhe
|
||||
|
||||
Stage 之前先执行 V3 物理解码与 header 校验。Stage 按上述规范检查连续序号、源截点、canonical 工具结果 wrapper、命名的 plugin 来源、delivery 归属及传入的目录证据。它不运行完整的已发布 V3 语义恢复器,也不复制 V2→V3 的事件/内容允许列表。完整恢复还会应用下述 V4 目标规则;物理解析、Stage 转换与目标恢复是不同的检查。
|
||||
|
||||
只转换列明的消息及字段。无关事件及任意 JSON 不会因字符串或数字匹配而获得新含义。固定的 `RELEASED_V3_EVENT_TYPES` 集合独立于当前 writer 区分源事件和扩展。未知必需事件不是有效的 V3 输入;未知可忽略事件名称变为 `plugin:<original-name>`,载荷不变。不隐含通用源 schema 校验或递归数字字段推断。
|
||||
只转换列明的消息及字段。无关事件及任意 JSON 不会因字符串或数字匹配而获得新含义。固定的 `RELEASED_V3_EVENT_TYPES` 集合独立于当前 writer 区分源事件和扩展。Stage 在解释载荷或查询 V4 词汇之前拒绝 V3 未知必需事件,包括 V4 已认识的名称。未知可忽略事件名称变为 `plugin:<original-name>`,载荷不变。不隐含通用源 schema 校验或递归数字字段推断。
|
||||
|
||||
只有 canonical V3 `tool/result` wrapper 具有保留信息的转换。其他被解释位置中的已退役 `tool-result` block 由目标接纳拒绝,不作为无效 V4 block 保留。原生 V4 在 recoverable 后缀抑制前应用同样的标签拒绝。检查只覆盖以下位置:
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { assertReleasedV3Header } from '@deepseek-ai/dsh-session-format-v2-to-v3
|
||||
import { mapEventMessages, rewriteV3MessageSource } from './sources.ts'
|
||||
import { liftToolResult } from './tool-role.ts'
|
||||
import { migrateV3EventContent } from './content.ts'
|
||||
import { namespaceV3OpaqueEvent } from './extension-identities.ts'
|
||||
import { namespaceV3OpaqueEvent, RELEASED_V3_EVENT_TYPES } from './extension-identities.ts'
|
||||
import { assertReleasedV4Header, validateDeliveryAccepted } from './validation.ts'
|
||||
import { catalogFact, childCatalogSource, childCatalogFact, childCatalogSubject } from './facts.ts'
|
||||
|
||||
@@ -74,6 +74,11 @@ class ReleasedV3ToV4Stage implements SessionFormatMigrationStage {
|
||||
}
|
||||
const opaque = namespaceV3OpaqueEvent(event)
|
||||
if (opaque !== event) { context.emitEvent(opaque); return }
|
||||
if (!RELEASED_V3_EVENT_TYPES.has(event.type)) {
|
||||
throw new SessionFormatUnsupportedMigrationError(
|
||||
`format v3 contains unknown event type ${JSON.stringify(event.type)} at seq ${event.seq}`,
|
||||
)
|
||||
}
|
||||
const rewritten = mapEventMessages(event, (message) => {
|
||||
const source = message['source']
|
||||
if (!isSessionFormatJsonObject(source)) return message
|
||||
|
||||
@@ -191,7 +191,7 @@ describe('unknown V3 event identities', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps required unknown event identity for the source reader to reject', () => {
|
||||
it('leaves required unknown event identities for the migration stage to reject', () => {
|
||||
const unknown = event('future', {})
|
||||
expect(namespaceV3OpaqueEvent(unknown)).toBe(unknown)
|
||||
const known = event('turn/start', { turn: 1 })
|
||||
|
||||
@@ -40,6 +40,39 @@ function restoreHistorical(events: readonly SessionFormatEvent[], sourceHeader:
|
||||
}
|
||||
|
||||
describe('V3 to V4 source preservation', () => {
|
||||
it('rejects required V3 developer events while preserving ignorable events and native V4 admission', () => {
|
||||
const rows: SessionFormatEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
|
||||
{ type: 'developer/message', seq: 2, time: 3, surfaceOp: 'append', data: {
|
||||
turn: 1, step: 1, message: { id: 'developer', role: 'developer', source: { kind: 'tool-registry' }, content: [] },
|
||||
} },
|
||||
{ type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const before = structuredClone(rows)
|
||||
expect(() => migrate(rows)).toThrow('format v3 contains unknown event type "developer/message" at seq 2')
|
||||
expect(() => restore(rows)).toThrow('format v3 contains unknown event type "developer/message" at seq 2')
|
||||
expect(() => restoreHistorical(rows, header)).toThrow('unknown event type')
|
||||
const ignorable = rows.map(row => row.type === 'developer/message' ? { ...row, ignorable: true } : row)
|
||||
expect(restore(ignorable).events).toEqual(ignorable.map(row =>
|
||||
row.type === 'developer/message' ? { ...row, type: 'plugin:developer/message' } : row))
|
||||
const native = sessionFormatCatalog.createRestore({ type: 'session', ...header, version: 4 }, {
|
||||
recovery: 'strict', validation: 'current',
|
||||
})
|
||||
for (const row of rows) native.decodeRow(row)
|
||||
expect(native.finish().events).toEqual(rows)
|
||||
expect(rows).toEqual(before)
|
||||
})
|
||||
|
||||
it('rejects generic required V3 extensions before target vocabulary admission', () => {
|
||||
const required = { ...fact, type: 'external/required' }
|
||||
expect(() => migrate([required])).toThrow('format v3 contains unknown event type "external/required" at seq 0')
|
||||
expect(() => restore([required])).toThrow('format v3 contains unknown event type "external/required" at seq 0')
|
||||
const ignorable = { ...required, ignorable: true }
|
||||
expect(restore([ignorable]).events).toEqual([{ ...ignorable, type: 'plugin:external/required' }])
|
||||
})
|
||||
|
||||
it('changes only the header version and retains event objects, payloads, timestamps, and coordinates', () => {
|
||||
const rows = [fact, delivery(3)]
|
||||
const before = JSON.stringify({ header, rows })
|
||||
|
||||
@@ -302,6 +302,22 @@ describe.each(modes)('EOF migration refusal ($compression, $access)', ({ compres
|
||||
await expectOnlyGenerations([path])
|
||||
})
|
||||
|
||||
it.each(['developer/message', 'external/required'])('refuses invalid required V3 %s before publishing', async (type) => {
|
||||
const row = { type, surfaceOp: 'append', data: {
|
||||
turn: 1, step: 1, message: {
|
||||
id: 'invalid-v3', role: 'developer', source: { kind: 'tool-registry' }, content: [],
|
||||
},
|
||||
} }
|
||||
const path = await store(3, compression, [...releasedPrefix, row])
|
||||
const original = await observe(path)
|
||||
const ctx = await mount(compression)
|
||||
await expectRefusal(ctx, access, path,
|
||||
`format v3 contains unknown event type ${JSON.stringify(type)} at seq ${releasedPrefix.length}`
|
||||
+ '; source v3 artifact remains unchanged (raw log: ' + path + ')')
|
||||
expect(await observe(path)).toEqual(original)
|
||||
await expectOnlyGenerations([path])
|
||||
})
|
||||
|
||||
it.each([5, 99])('retains a V3 watermark claiming V%s unchanged through native reopening', async (sessionFormatVersion) => {
|
||||
const marker = { type: 'session-log-deepseek/delivery-accepted', data: {
|
||||
sessionId: id, throughSeq: 0, sessionFormatVersion,
|
||||
|
||||
Reference in new issue
Block a user