fix(session-query): reject misplaced surface ops

This commit is contained in:
Hypatia May
2026-07-13 16:05:11 +08:00
parent 3d3789bf2d
commit 84e6f72ef5
12 changed files with 182 additions and 114 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 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<unknown>()
for (const source of sources ?? []) {
if (unique.has(source)) return 'sourceEventSeqs must not contain duplicates'
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 (typeof source !== 'number' || !Number.isInteger(source) || source < 0) {
return `sourceEventSeqs contains invalid seq ${String(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 {

View File

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

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

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