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

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