Sfoglia il codice sorgente

fix(session): retain unknown required events for vocabulary validation

Tianyi Cui 2 settimane fa
parent
commit
920e917988

+ 2 - 2
packages/session/session-format-v2-to-v3/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/session/session-format-v2-to-v3/README.md
-README.md: 6d5cb9b8e79df6796faa5f828fd89589361d3a14
-README.zh.md: 945410e64f0ef256a09942385c42cf4a4a0f3e85
+README.md: d14dc7a374e03809b0138d6f9198837a063515e8
+README.zh.md: b16670407e7932791ed8cc0058bd212c41a30143

+ 1 - 1
packages/session/session-format-v2-to-v3/README.md

@@ -57,7 +57,7 @@ The [stage](src/migration.ts) emits synchronously, retains coordinate mappings,
 
 The [validator](src/validation.ts) checks canonical envelopes, system payloads, open-step ownership, and protected-head operations independently. Native V3 also admits in-history system appends, non-head replacements, and compaction of non-head system nodes. Frozen ordinary relationship validation receives a private system/PTC/repair view composed with the canonical-endpoint view; the result retains the original V3 events, IDs, and actual target generation. Generated repair-ID suffixes remain historical identities, not current sequence coordinates. Frozen v0-to-v1 and v1-to-v2 semantics remain unchanged.
 
-The [codec](src/codec.ts) shares frozen V2 physical framing and provenance encoding and validates V3 event-local rules before encoding and after decoding. Raw structural and unsupported-event refusal precede recoverable decoding and cannot be hidden by suffix recovery. Strict reads reject canonical format errors immediately; committed-prefix recovery withholds an invalid suffix and rejects it if a later `turn/end` establishes a commit. [Admission tests](tests/admission.spec.ts) cover malformed durable payloads, repair identities, protected-head violations, and compaction reference remapping. No runtime invariant companion is published because this library owns no independently observable runtime registrations or state replicas.
+The [codec](src/codec.ts) shares frozen V2 physical framing and provenance encoding and validates V3 event-local rules before encoding and after decoding. Retired system-header, malformed system-payload, and required predecessor-PTC refusal precede recoverable decoding. Unclassified event metadata stays uninterpreted until vocabulary-aware restoration, which refuses unknown required types and validates installed log-only envelopes; the codec cannot discard these required records as malformed canonical tails. Strict reads reject canonical format errors immediately; committed-prefix recovery withholds an invalid suffix and rejects it if a later `turn/end` establishes a commit. [Admission tests](tests/admission.spec.ts) cover malformed durable payloads, repair identities, protected-head violations, and compaction reference remapping. No runtime invariant companion is published because this library owns no independently observable runtime registrations or state replicas.
 
 </details>
 

+ 1 - 1
packages/session/session-format-v2-to-v3/README.zh.md

@@ -57,7 +57,7 @@ const targetHeader = sessionFormatV2ToV3.migrateHeader(sourceHeader)
 
 [校验器](src/validation.ts)独立检查规范信封、系统载荷、开放步骤归属与受保护的头节点操作。原生 V3 也接受历史内系统消息追加、非头节点替换,以及非头系统节点的压缩。冻结的普通关系校验接收由私有 system/PTC/repair 视图与规范端点视图组合而成的输入;结果保留原始 V3 事件、ID 与实际目标代次。生成的修复 ID 后缀仍是历史身份,而非当前序列坐标。冻结的 v0-to-v1 与 v1-to-v2 语义保持不变。
 
-[编解码器](src/codec.ts)共享冻结的 V2 物理分帧与溯源编码,并在编码前及解码后验证 V3 事件本地规则。原始结构与不受支持事件的拒绝先于可恢复解码,不能被后缀恢复隐藏。严格读取立即拒绝规范格式错误;已提交前缀恢复不产出无效后缀,若后续 `turn/end` 证明该后缀已提交,则会拒绝它。[准入测试](tests/admission.spec.ts)覆盖畸形持久化载荷、修复身份、受保护头节点违规与压缩引用重映射。本库不拥有可独立观察的运行时注册或状态副本,因此不发布运行时不变量伴随入口。
+[编解码器](src/codec.ts)共享冻结的 V2 物理分帧与溯源编码,并在编码前及解码后验证 V3 事件本地规则。已退役系统头字段、畸形系统载荷与必需前代 PTC 的拒绝先于可恢复解码。未分类事件的元数据在识别事件词汇的恢复阶段之前保持不解释;该阶段拒绝未知必需类型并校验已安装的仅日志信封,编解码器不能把这些必需记录当作畸形规范尾部丢弃。严格读取立即拒绝规范格式错误;已提交前缀恢复不产出无效后缀,若后续 `turn/end` 证明该后缀已提交,则会拒绝它。[准入测试](tests/admission.spec.ts)覆盖畸形持久化载荷、修复身份、受保护头节点违规与压缩引用重映射。本库不拥有可独立观察的运行时注册或状态副本,因此不发布运行时不变量伴随入口。
 
 </details>
 

+ 2 - 2
packages/session/session-format-v2-to-v3/src/payload.ts

@@ -203,7 +203,7 @@ function assertFeedback(type: string, data: SessionFormatJsonObject): void {
 
 /**
  * Validate one canonical V3 event without interpreting plugin-owned payloads or log relationships.
- * Unknown ignorable types retain opaque surface metadata without contributing to the surface.
+ * Unclassified metadata is deferred to vocabulary-aware restoration; unknown required types must not become recoverable corruption.
  * @param event - decoded logical event.
  * @param knownEventTypes - additional installed event types whose envelopes are interpreted.
  */
@@ -216,7 +216,7 @@ export function assertV3Event(event: SessionFormatEvent, knownEventTypes?: Reado
     || event.type === 'tool/ptc-dispatch-start' || event.type === 'tool/ptc-dispatch'
     || event.type === 'feedback/message-put' || event.type === 'feedback/message-delete'
     || knownEventTypes?.has(event.type) === true)
-  const opaque = value['ignorable'] === true && !known
+  const opaque = !known
   keys(value, ['type', 'seq', 'time', 'data'],
     SURFACE_TYPES.has(event.type) || opaque ? ['ignorable', 'surfaceOp', 'sourceEventSeqs'] : ['ignorable'], subject)
   if (typeof event.type !== 'string') throw new SessionFormatError(`${subject} type must be a string`)

+ 18 - 0
packages/session/session-format-v2-to-v3/tests/canonical-envelopes.spec.ts

@@ -439,6 +439,24 @@ describe('native V3 physical admission', () => {
 })
 
 describe('V3 full artifact relationships', () => {
+  it.each([{ surfaceOp: 'append' }, { sourceEventSeqs: [] }])('defers unclassified required metadata %j until vocabulary-aware restoration', (metadata) => {
+    const required = event('future/required', 0, {}, metadata)
+    const output = new SessionFormatEventCollector()
+    const decoder = releasedV3SessionFormatCodec.createDecoder({ type: 'session', ...header, version: 3 }, 'recoverable')
+    decoder.decodeRow(required, output)
+    expect(decoder.finish(output)).toBe(0)
+    expect(output.values).toEqual([required])
+    expect(() => restore(artifact(output.values))).toThrow(/unknown event type/)
+    expect(() => restoreReleasedV3Artifact(artifact(output.values), new Set(['future/required'])))
+      .toThrow(/unexpected field/)
+  })
+
+  it('admits installed required extension payloads without surface metadata', () => {
+    const required = event('future/required', 0, { extension: true })
+    const value = artifact([required])
+    expect(restoreReleasedV3Artifact(value, new Set(['future/required']))).toBe(value)
+  })
+
   it('accepts repeated replacement whose endpoints reverse numeric order', () => {
     const source = [
       ...opening(), user(2), user(3), user(4),

+ 22 - 0
packages/session/session-persistence-jsonl/tests/v3-event-admission.spec.ts

@@ -47,6 +47,28 @@ describe('native V3 event admission at EOF', () => {
     return path
   }
 
+  it.each([{ surfaceOp: 'append' }, { sourceEventSeqs: [] }])('refuses unknown required metadata %j without truncating a provider append', async (metadata) => {
+    const event = { type: 'future/required', seq: 0, time: 1, data: {}, ...metadata }
+    const bytes = Buffer.from([header, event].map(row => JSON.stringify(row)).join('\n') + '\n')
+    expect(scanLog(bytes)).toMatchObject({ events: [event], committedBytes: bytes.length })
+    const path = await store(bytes)
+    const sourceStat = await stat(path)
+    for (const access of ['read', 'write'] as const) {
+      const operation = async () => {
+        const handle = await ctx.sessionPersistence.open(id, access)
+        try {
+          if (access === 'read') await handle.read()
+          else await handle.append([{ type: 'turn/start', seq: SessionSeq(1), time: 2, data: { turn: 1 } }])
+        } finally {
+          await handle.close()
+        }
+      }
+      await expect(operation()).rejects.toThrow('unknown to this harness and not marked ignorable')
+      expect(await readFile(path)).toEqual(bytes)
+      expect(await stat(path)).toMatchObject({ dev: sourceStat.dev, ino: sourceStat.ino })
+    }
+  })
+
   it.each(obsoleteTypes)('scanLog refuses a complete required %s EOF row', (type) => {
     const bytes = Buffer.from(prefix + JSON.stringify(obsoleteEvent(type)) + '\n')
     expect(() => scanLog(bytes)).toThrow(SessionFormatUnsupportedError)