mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(session-query): reject misplaced surface ops
This commit is contained in:
@@ -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`
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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])] }])
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 —
|
||||
|
||||
Reference in New Issue
Block a user