Преглед изворни кода

fix(session-query): reject misplaced surface ops

Hypatia May пре 2 месеци
родитељ
комит
84e6f72ef5

+ 1 - 1
docs/cordis-catalog/services.md

@@ -232,7 +232,7 @@ list(): Session[]
 fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session
 ```
 
-Source: [`packages/core/session/src/index.ts:590`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:557`](../../packages/core/session/src/index.ts)
 
 ## `ctx.skills` — `SkillService`
 

+ 1 - 1
docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md

@@ -16,7 +16,7 @@ Session relationships are encoded across immutable headers, positional surface o
 
 ## Validation boundary
 
-Event tracing checks target existence before surface analysis. Before returning a trace it validates the whole loaded log through `dsh-session`'s shared provenance checker: only surface event types carry provenance, present arrays are nonempty and duplicate-free, every source is a known earlier seq, and every positional replacement names all surface nodes it removed. Provenance failures use `SESSION_QUERY_INVALID_PROVENANCE`; positional fold failures keep `SESSION_QUERY_INVALID_SURFACE`. `listEvents()` remains a surface-classification operation and does not acquire trace-specific provenance rejection.
+Event tracing checks target existence before surface analysis. Before returning a trace it validates the whole loaded log through `dsh-session`'s shared surface-metadata checker: surface markers obey event-type eligibility, provenance belongs only to surface event types, present arrays are nonempty and duplicate-free, every source is a known earlier seq, and every positional replacement names all surface nodes it removed. Surface-marker and positional-fold failures use `SESSION_QUERY_INVALID_SURFACE`; provenance failures use `SESSION_QUERY_INVALID_PROVENANCE`. `listEvents()` remains a surface-classification operation and does not acquire trace-specific provenance rejection.
 
 All returned records and arrays are detached. A known live event trace never consults persistence; persisted event traces preserve the exact-read list/load consistency check. Session lineage is necessarily a cross-corpus operation and therefore preserves cross-corpus persistence failure semantics.
 

+ 2 - 2
packages/core/session/README.md

@@ -49,8 +49,8 @@ Durable values need one accepted representation, not a check followed by a secon
 - `SurfaceOp` — how a surface node entered the linked list: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace nodes from `start` through `end` inclusive — both must be valid surface node seqs; `start === end` replaces a single node). Used by compaction to shadow old nodes without deleting them.
 - `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types.
 - `SurfaceNode` — `{ seq: number; prev: number | null; next: number | null }`, one node in the surface linked list.
-- `foldSurface(events)` — replay the canonical surface transitions into detached current nodes and actual replacement ranges, rejecting surface-eligible events that lack their mandatory marker. `SurfaceManager` shares the same transitions while retaining its incremental cache.
-- `validateSurfaceProvenance(event, knownSeqs, shadowedSeqs?)` — pure provenance-contract check shared by incremental invariant listeners and exact readers. It returns the first violation instead of choosing a caller's error taxonomy.
+- `foldSurface(events)` — replay the canonical surface transitions into detached current nodes and actual replacement ranges, rejecting misplaced, missing, malformed, or positionally invalid `surfaceOp` metadata while leaving provenance validation to callers. `SurfaceManager` shares the same transitions while retaining its incremental cache.
+- `validateSurfaceMetadata(event, knownSeqs?, shadowedSeqs?)` — canonical structural and provenance check shared by session acceptance, surface folding, incremental invariants, and exact readers. It tags violations as `surface` or `provenance` so callers retain their error taxonomy; omit `knownSeqs` for local shape validation only.
 - `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully-formed surface node (type is surface-eligible AND `surfaceOp` present); the second is the type-only check (is this one of the five `SurfaceEventType` values?), used to detect a surface-eligible event MISSING its marker — e.g. when validating a seed/load log.
 
 ### Request-header reconstruction (`request-header.ts`)

+ 12 - 45
packages/core/session/src/index.ts

@@ -15,7 +15,7 @@ import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
 import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
 import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
 import { snapshotJsonValue } from './json.ts'
-import { SurfaceManager, isSurfaceEligibleType } from './surface.ts'
+import { SurfaceManager, validateSurfaceMetadata } from './surface.ts'
 import { foldRequestHeader } from './request-header.ts'
 
 export * from './types.ts'
@@ -23,7 +23,7 @@ export { isJsonValue, snapshotJsonValue } from './json.ts'
 export type { JsonValue } from './json.ts'
 export { interruptedTurnClosers } from './repair.ts'
 export type { SurfaceFoldReplacement, SurfaceFoldResult, SurfaceNode } from './surface.ts'
-export { foldSurface, isSurfaceEvent, isSurfaceEligibleType, validateSurfaceProvenance } from './surface.ts'
+export { foldSurface, isSurfaceEvent, isSurfaceEligibleType, validateSurfaceMetadata } from './surface.ts'
 export { isToolPairingBalanced } from './tool-pairing.ts'
 export { applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from './request-header.ts'
 
@@ -157,43 +157,6 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
   return deepFreeze(record as unknown as SessionHeader)
 }
 
-/** Validate the runtime shape of surface metadata after its JSON snapshot. */
-function assertSurfaceMetadataShape(
-  type: string,
-  surfaceOp: unknown,
-  sourceEventSeqs: unknown,
-): void {
-  const eligible = isSurfaceEligibleType(type)
-  if (!eligible) {
-    if (surfaceOp !== undefined || sourceEventSeqs !== undefined) {
-      throw new Error(`session event "${type}" is not surface-eligible and cannot carry surface metadata`)
-    }
-    return
-  }
-  if (surfaceOp === undefined) {
-    throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`)
-  }
-  if (surfaceOp !== 'append') {
-    if (surfaceOp === null || typeof surfaceOp !== 'object' || Array.isArray(surfaceOp)) {
-      throw new Error(`session event "${type}" carries an invalid surfaceOp`)
-    }
-    const op = surfaceOp as Record<string, unknown>
-    const keys = Object.keys(op)
-    if (keys.length !== 3 || !Object.hasOwn(op, 'op') || !Object.hasOwn(op, 'start') || !Object.hasOwn(op, 'end')
-      || op['op'] !== 'replace'
-      || typeof op['start'] !== 'number' || !Number.isSafeInteger(op['start']) || op['start'] < 0
-      || typeof op['end'] !== 'number' || !Number.isSafeInteger(op['end']) || op['end'] < 0) {
-      throw new Error(`session event "${type}" carries an invalid replace surfaceOp`)
-    }
-  }
-  if (sourceEventSeqs !== undefined) {
-    if (!Array.isArray(sourceEventSeqs)
-      || sourceEventSeqs.some(seq => typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0)) {
-      throw new Error(`session event "${type}" sourceEventSeqs must contain non-negative safe integers`)
-    }
-  }
-}
-
 /** Validate the fixed event envelope after one-pass JSON materialization. */
 function assertSessionEventEnvelope(value: Record<string, unknown>, index: number): asserts value is SessionEvent {
   const event = value
@@ -312,12 +275,15 @@ export class Session {
         // this at compile time via its typed overload; a seed arrives as raw
         // SessionEvent[] (replay/fork/load), bypassing that, so re-check at
         // runtime here rather than silently resuming with empty history.
-        const structural = snapshot as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown }
+        let violation: ReturnType<typeof validateSurfaceMetadata>
         try {
-          assertSurfaceMetadataShape(snapshot.type, structural.surfaceOp, structural.sourceEventSeqs)
+          violation = validateSurfaceMetadata(snapshot)
         } catch (error: unknown) {
           throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`)
         }
+        if (violation !== undefined) {
+          throw new Error(`invalid seed event at index ${index}: ${violation.message}`)
+        }
         return deepFreeze(snapshot)
       })
     }
@@ -392,11 +358,12 @@ export class Session {
     if (surfaceMetadataSnapshot === undefined) {
       throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`)
     }
-    assertSurfaceMetadataShape(
+    const surfaceViolation = validateSurfaceMetadata({
       type,
-      (surfaceMetadataSnapshot as { surfaceOp?: unknown }).surfaceOp,
-      (surfaceMetadataSnapshot as { sourceEventSeqs?: unknown }).sourceEventSeqs,
-    )
+      seq: this.log.length,
+      ...(surfaceMetadataSnapshot as { surfaceOp?: unknown; sourceEventSeqs?: unknown }),
+    })
+    if (surfaceViolation !== undefined) throw new Error(surfaceViolation.message)
 
     const entry = attachments.get(this)
     if (entry?.appending) {

+ 102 - 37
packages/core/session/src/surface.ts

@@ -82,46 +82,110 @@ export interface SurfaceFoldResult {
 }
 
 /**
- * Validate one event's logged provenance against the preceding log and the
- * surface nodes it actually shadows.
- * @param event - event whose optional `sourceEventSeqs` is being checked.
- * @param knownSeqs - seqs preceding `event` in the same log.
+ * Validate one event's surface metadata through the canonical structural and
+ * provenance contract. Structural validation always runs; when `knownSeqs` is
+ * supplied, provenance must additionally name unique known earlier events and
+ * cover every shadowed surface node. The tagged result lets callers retain
+ * their own surface-versus-provenance error taxonomy.
+ * @param event - event whose `surfaceOp` and `sourceEventSeqs` are being checked.
+ * @param knownSeqs - seqs preceding `event`, or `undefined` for local shape validation only.
  * @param shadowedSeqs - surface nodes directly removed by this event.
- * @returns the first contract violation, or `undefined` when provenance is valid.
+ * @returns the first tagged contract violation, or `undefined` when valid.
  */
-export function validateSurfaceProvenance(
-  event: SessionEvent,
-  knownSeqs: ReadonlySet<number>,
+export function validateSurfaceMetadata(
+  event: Pick<SessionEvent, 'type' | 'seq'> & {
+    surfaceOp?: unknown
+    sourceEventSeqs?: unknown
+  },
+  knownSeqs?: ReadonlySet<number>,
   shadowedSeqs: readonly number[] = [],
-): string | undefined {
-  const sources = (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs
-  if (sources !== undefined && !isSurfaceEligibleType(event.type)) {
-    return `${event.type} cannot carry sourceEventSeqs (non-surface event)`
+): { kind: 'surface' | 'provenance'; message: string } | undefined {
+  const eligible = isSurfaceEligibleType(event.type)
+  const surfaceOp = event.surfaceOp
+  const sources = event.sourceEventSeqs
+
+  if (!eligible && surfaceOp !== undefined) {
+    return {
+      kind: 'surface',
+      message: `session event "${event.type}" is not surface-eligible and cannot carry surfaceOp`,
+    }
+  }
+  if (eligible && surfaceOp === undefined) {
+    return {
+      kind: 'surface',
+      message: `session event "${event.type}" is surface-eligible and requires a surfaceOp marker`,
+    }
+  }
+  if (surfaceOp !== undefined && surfaceOp !== 'append') {
+    if (surfaceOp === null || typeof surfaceOp !== 'object' || Array.isArray(surfaceOp)) {
+      return {
+        kind: 'surface',
+        message: `session event "${event.type}" carries an invalid surfaceOp`,
+      }
+    }
+    const op = surfaceOp as Record<string, unknown>
+    const keys = Object.keys(op)
+    if (keys.length !== 3 || !Object.hasOwn(op, 'op') || !Object.hasOwn(op, 'start') || !Object.hasOwn(op, 'end')
+      || op['op'] !== 'replace'
+      || typeof op['start'] !== 'number' || !Number.isSafeInteger(op['start']) || op['start'] < 0
+      || typeof op['end'] !== 'number' || !Number.isSafeInteger(op['end']) || op['end'] < 0) {
+      return {
+        kind: 'surface',
+        message: `session event "${event.type}" carries an invalid replace surfaceOp`,
+      }
+    }
+  }
+
+  if (sources !== undefined && !eligible) {
+    return {
+      kind: 'provenance',
+      message: `${event.type} cannot carry sourceEventSeqs (non-surface event)`,
+    }
   }
   if (sources !== undefined && !Array.isArray(sources)) {
-    return `sourceEventSeqs on event at seq ${event.seq} must be an array when present`
+    return {
+      kind: 'provenance',
+      message: `sourceEventSeqs on event at seq ${event.seq} must be an array when present`,
+    }
   }
-  if (Array.isArray(sources) && sources.length === 0) {
-    return 'sourceEventSeqs must not be empty when present'
+  if (Array.isArray(sources)
+    && sources.some(source => typeof source !== 'number' || !Number.isSafeInteger(source) || source < 0)) {
+    return {
+      kind: 'provenance',
+      message: `session event "${event.type}" sourceEventSeqs must contain non-negative safe integers`,
+    }
   }
+  if (knownSeqs === undefined) return
 
-  const unique = new Set<unknown>()
-  for (const source of sources ?? []) {
-    if (unique.has(source)) return 'sourceEventSeqs must not contain duplicates'
-    unique.add(source)
-    if (typeof source !== 'number' || !Number.isInteger(source) || source < 0) {
-      return `sourceEventSeqs contains invalid seq ${String(source)}`
+  const sourceSeqs = sources as number[] | undefined
+  if (sourceSeqs !== undefined && sourceSeqs.length === 0) {
+    return { kind: 'provenance', message: 'sourceEventSeqs must not be empty when present' }
+  }
+
+  const unique = new Set<number>()
+  for (const source of sourceSeqs ?? []) {
+    if (unique.has(source)) {
+      return { kind: 'provenance', message: 'sourceEventSeqs must not contain duplicates' }
     }
+    unique.add(source)
     if (source >= event.seq) {
-      return `sourceEventSeqs must reference earlier events: ${source} >= current seq ${event.seq}`
+      return {
+        kind: 'provenance',
+        message: `sourceEventSeqs must reference earlier events: ${source} >= current seq ${event.seq}`,
+      }
+    }
+    if (!knownSeqs.has(source)) {
+      return { kind: 'provenance', message: `sourceEventSeqs references unknown seq ${source}` }
     }
-    if (!knownSeqs.has(source)) return `sourceEventSeqs references unknown seq ${source}`
   }
 
-  const sourceSet = new Set(sources ?? [])
+  const sourceSet = new Set(sourceSeqs ?? [])
   const missing = shadowedSeqs.filter(seq => !sourceSet.has(seq))
   if (missing.length > 0) {
-    return `surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`
+    return {
+      kind: 'provenance',
+      message: `surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`,
+    }
   }
   return undefined
 }
@@ -147,25 +211,26 @@ function applySurfaceEvent(
   state: SurfaceFoldState,
   event: SessionEvent,
 ): SurfaceFoldReplacement | undefined {
+  const violation = validateSurfaceMetadata(event)
+  if (violation?.kind === 'surface') throw new Error(violation.message)
   if (!isSurfaceEligibleType(event.type)) return
-  if (!isSurfaceEvent(event)) {
-    throw new Error(`surface event "${event.type}" (seq ${event.seq}) carries no surfaceOp marker`)
-  }
+  // The canonical metadata validation above proves this runtime shape.
+  const surfaceEvent = event as SurfaceEvent
 
-  if (event.surfaceOp === 'append') {
+  if (surfaceEvent.surfaceOp === 'append') {
     const tail = state.nodes.length > 0 ? state.nodes[state.nodes.length - 1] : undefined
-    const node: SurfaceNode = { seq: event.seq, prev: tail?.seq ?? null, next: null }
-    if (tail) tail.next = event.seq
+    const node: SurfaceNode = { seq: surfaceEvent.seq, prev: tail?.seq ?? null, next: null }
+    if (tail) tail.next = surfaceEvent.seq
     state.nodes.push(node)
-    state.nodeBySeq.set(event.seq, node)
+    state.nodeBySeq.set(surfaceEvent.seq, node)
     return
   }
 
   return {
-    seq: event.seq,
-    start: event.surfaceOp.start,
-    end: event.surfaceOp.end,
-    shadowedSeqs: replaceSurface(state, event.seq, event.surfaceOp),
+    seq: surfaceEvent.seq,
+    start: surfaceEvent.surfaceOp.start,
+    end: surfaceEvent.surfaceOp.end,
+    shadowedSeqs: replaceSurface(state, surfaceEvent.seq, surfaceEvent.surfaceOp),
   }
 }
 
@@ -215,7 +280,7 @@ function replaceSurface(
  * models cannot disagree with `deriveMessages()` about replacement ranges.
  * @param events - session events in contiguous seq order.
  * @returns the current surface and every positional replacement.
- * @throws when a surface-eligible event lacks its mandatory `surfaceOp`, or a
+ * @throws when an event violates the `surfaceOp` type/marker contract, or a
  * replacement names nodes that are absent or reversed on the current surface.
  */
 export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult {

+ 7 - 4
packages/core/session/tests/session.spec.ts

@@ -313,10 +313,13 @@ describe('Session', () => {
     expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
   })
 
-  it('adds seed context when surface validation throws a non-Error value', () => {
+  it.each([
+    ['an Error', new Error('validator failed'), 'validator failed'],
+    ['a non-Error value', 'validator failed', 'invalid surface metadata'],
+  ] as const)('adds seed context when surface validation throws %s', (_name, failure, expected) => {
     const originalHasOwn = Object.hasOwn
     const hasOwn = vi.spyOn(Object, 'hasOwn').mockImplementation((object: object, property: PropertyKey): boolean => {
-      if ((object as Record<string, unknown>)['op'] === 'replace') throw 'validator failed'
+      if ((object as Record<string, unknown>)['op'] === 'replace') throw failure
       return originalHasOwn(object, property)
     })
     const seed = [{
@@ -329,7 +332,7 @@ describe('Session', () => {
 
     try {
       expect(() => new Session(SessionId('seed-non-error-metadata-failure'), seed))
-        .toThrow('invalid seed event at index 0: invalid surface metadata')
+        .toThrow(`invalid seed event at index 0: ${expected}`)
     } finally {
       hasOwn.mockRestore()
     }
@@ -468,7 +471,7 @@ describe('Session', () => {
       'turn/start',
       { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
       { surfaceOp: 'append' },
-    )).toThrow(/not surface-eligible and cannot carry surface metadata/)
+    )).toThrow(/not surface-eligible and cannot carry surfaceOp/)
     expect(() => new Session(SessionId('non-surface-metadata-seed'), [{
       type: 'turn/start',
       seq: 0,

+ 30 - 12
packages/core/session/tests/surface.spec.ts

@@ -6,7 +6,7 @@ import {
   foldSurface,
   isSurfaceEligibleType,
   isSurfaceEvent,
-  validateSurfaceProvenance,
+  validateSurfaceMetadata,
 } from '@deepseek-ai/dsh-session'
 import { CallId } from '@deepseek-ai/dsh-llm'
 
@@ -31,11 +31,11 @@ function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent {
   } as unknown as SessionEvent
 }
 
-describe('validateSurfaceProvenance', () => {
+describe('validateSurfaceMetadata', () => {
   it('accepts absent or valid provenance and complete replacement coverage', () => {
-    expect(validateSurfaceProvenance(provenanceEvent(0, undefined), new Set()))
+    expect(validateSurfaceMetadata(provenanceEvent(0, undefined), new Set()))
       .toBeUndefined()
-    expect(validateSurfaceProvenance(provenanceEvent(2, [0, 1]), new Set([0, 1]), [1]))
+    expect(validateSurfaceMetadata(provenanceEvent(2, [0, 1]), new Set([0, 1]), [1]))
       .toBeUndefined()
   })
 
@@ -47,28 +47,33 @@ describe('validateSurfaceProvenance', () => {
       data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
       sourceEventSeqs: [0],
     } as unknown as SessionEvent
-    expect(validateSurfaceProvenance(event, new Set([0])))
-      .toMatch(/cannot carry sourceEventSeqs/)
+    expect(validateSurfaceMetadata(event, new Set([0])))
+      .toEqual({
+        kind: 'provenance',
+        message: 'turn/start cannot carry sourceEventSeqs (non-surface event)',
+      })
   })
 
   it.each([
     ['a non-array', 1, 'invalid', new Set([0]), [], /must be an array/],
     ['an empty array', 1, [], new Set([0]), [], /must not be empty/],
     ['duplicates', 1, [0, 0], new Set([0]), [], /must not contain duplicates/],
-    ['a non-number', 1, ['0'], new Set([0]), [], /invalid seq 0/],
-    ['a fractional number', 1, [0.5], new Set([0]), [], /invalid seq 0\.5/],
-    ['a negative number', 1, [-1], new Set([0]), [], /invalid seq -1/],
+    ['a non-number', 1, ['0'], new Set([0]), [], /non-negative safe integers/],
+    ['a fractional number', 1, [0.5], new Set([0]), [], /non-negative safe integers/],
+    ['a negative number', 1, [-1], new Set([0]), [], /non-negative safe integers/],
     ['a self reference', 1, [1], new Set([0]), [], /must reference earlier events/],
     ['an unknown earlier seq', 2, [1], new Set([0]), [], /references unknown seq 1/],
     ['incomplete replacement coverage', 2, [0], new Set([0, 1]), [0, 1], /missing 1/],
   ] as const)(
     'returns the first violation for %s',
     (_name, seq, sources, knownSeqs, shadowedSeqs, expected) => {
-      expect(validateSurfaceProvenance(
+      const violation = validateSurfaceMetadata(
         provenanceEvent(seq, sources),
         knownSeqs,
         shadowedSeqs,
-      )).toMatch(expected)
+      )
+      expect(violation?.kind).toBe('provenance')
+      expect(violation?.message).toMatch(expected)
     },
   )
 })
@@ -124,7 +129,20 @@ describe('SurfaceManager', () => {
     }
 
     expect(() => foldSurface([malformed]))
-      .toThrow(/surface event "user\/message" \(seq 0\) carries no surfaceOp marker/)
+      .toThrow(/surface-eligible and requires a surfaceOp marker/)
+  })
+
+  it('foldSurface rejects surfaceOp on a non-surface event', () => {
+    const malformed = {
+      type: 'turn/start',
+      seq: 0,
+      time: 1,
+      data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
+      surfaceOp: 'append',
+    } as unknown as SessionEvent
+
+    expect(() => foldSurface([malformed]))
+      .toThrow(/not surface-eligible and cannot carry surfaceOp/)
   })
 
   it('rebuilds a linked list from surfaceOp: append markers', () => {

+ 1 - 1
packages/session-query/session-query/README.md

@@ -14,7 +14,7 @@ This is trusted context-wide infrastructure. It performs no caller authorization
 
 Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. An event read or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations.
 
-`traceEvent()` validates the whole loaded log with `dsh-session`'s shared provenance checker before returning relationships: provenance arrays are nonempty and duplicate-free, references name known earlier events, only surface event types carry sources, and each positional replacement names every surface node it removed. Provenance violations fail with `SESSION_QUERY_INVALID_PROVENANCE`; positional fold failures remain `SESSION_QUERY_INVALID_SURFACE`. `listEvents()` only needs surface classification and deliberately does not enforce the trace-specific provenance contract.
+`traceEvent()` validates the whole loaded log with `dsh-session`'s shared surface-metadata checker before returning relationships: surface markers obey event-type eligibility, provenance arrays are nonempty and duplicate-free, references name known earlier events, and each positional replacement names every surface node it removed. Surface-marker and positional-fold violations fail with `SESSION_QUERY_INVALID_SURFACE`; provenance violations use `SESSION_QUERY_INVALID_PROVENANCE`. `listEvents()` only needs surface classification and deliberately does not enforce the trace-specific provenance contract.
 
 `SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_LINEAGE`, `SESSION_QUERY_INVALID_PROVENANCE`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`.
 

+ 3 - 3
packages/session-query/session-query/src/tracing.ts

@@ -1,6 +1,6 @@
 /** One-shot session-lineage and event-relationship tracing helpers. */
 
-import { foldSurface, validateSurfaceProvenance } from '@deepseek-ai/dsh-session'
+import { foldSurface, validateSurfaceMetadata } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
 import { SessionQueryError } from './config.ts'
 import type {
@@ -53,14 +53,14 @@ export function traceEventLog(
   const analysis = analyzeEventLog(sessionId, events)
   const knownSeqs = new Set<number>()
   for (const event of events) {
-    const violation = validateSurfaceProvenance(
+    const violation = validateSurfaceMetadata(
       event,
       knownSeqs,
       analysis.replacedEventSeqs.get(event.seq),
     )
     if (violation !== undefined) {
       throw new SessionQueryError(
-        `invalid session provenance: ${violation}`,
+        `invalid session provenance: ${violation.message}`,
         'SESSION_QUERY_INVALID_PROVENANCE',
       )
     }

+ 17 - 0
packages/session-query/session-query/tests/tracing.spec.ts

@@ -390,6 +390,23 @@ describe('session event tracing', () => {
       .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_PROVENANCE'))
   })
 
+  it('rejects surfaceOp on a non-surface event as an invalid surface', async () => {
+    const durable = header('invalid-non-surface-op')
+    const events = [{
+      type: 'turn/start',
+      seq: 0,
+      time: 1,
+      data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
+      surfaceOp: 'append',
+    }] as unknown as SessionEvent[]
+    TracePersistence.reset([{ meta: durable, events }])
+    const ctx = await queryContext()
+    await ctx.plugin(TracePersistence)
+
+    await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
+      .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
+  })
+
   it('keeps listEvents tolerant of malformed provenance alone', async () => {
     const durable = header('list-regression')
     TracePersistence.reset([{ meta: durable, events: [appendEvent(0), appendEvent(1, [0, 0])] }])

+ 1 - 1
packages/support/invariants/README.md

@@ -28,7 +28,7 @@ await ctx.plugin(Invariants)
 Session log (per session):
 
 - **`seq` strictly increases** — the spine of replay equivalence.
-- **surface provenance is valid** — `sourceEventSeqs` uses the shared `dsh-session` checker for type eligibility, nonempty unique earlier references, and complete replacement coverage.
+- **surface metadata is valid** — `surfaceOp` and `sourceEventSeqs` use the shared `dsh-session` checker for type eligibility, structural shape, nonempty unique earlier references, and complete replacement coverage.
 - **turns pair and nest** — `turn/start` opens a turn, `turn/end` closes the matching one; no overlapping turns.
 - **steps nest in turns** — `step/start` opens a step in the open turn; `step/end` closes the matching step.
 - **chunks belong to an open step** — `step/start` precedes its `assistant/chunk`s.

+ 5 - 7
packages/support/invariants/src/index.ts

@@ -26,8 +26,7 @@ import {
   Session,
   SessionId,
   foldRequestHeader,
-  isSurfaceEligibleType,
-  validateSurfaceProvenance,
+  validateSurfaceMetadata,
 } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
 
@@ -131,9 +130,8 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr
   // SurfaceEvent's mandatory surfaceOp is too strict here — we need to
   // CHECK whether surface metadata is present, not assume it.
   const se = event as SessionEvent<SurfaceEventType>
-  if (!isSurfaceEligibleType(event.type) && se.surfaceOp !== undefined) {
-    throw new InvariantError(`${event.type} cannot carry surfaceOp (non-surface event)`)
-  }
+  const metadataViolation = validateSurfaceMetadata(event)
+  if (metadataViolation !== undefined) throw new InvariantError(metadataViolation.message)
 
   // Fold this event into the tracked surface linked list, validating the
   // replace contract as we go. `append` adds a tail node; `replace` shadows a
@@ -160,13 +158,13 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr
     }
   }
 
-  const provenanceViolation = validateSurfaceProvenance(
+  const provenanceViolation = validateSurfaceMetadata(
     event,
     trace.knownSeqs,
     shadowed,
   )
   if (provenanceViolation !== undefined) {
-    throw new InvariantError(provenanceViolation)
+    throw new InvariantError(provenanceViolation.message)
   }
 
   // Boundary/step-scoped events have explicit cases; every OTHER event type —