refactor(session): centralize surface provenance validation
This commit is contained in:
@@ -48,6 +48,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
- `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.
|
||||
- `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`)
|
||||
|
||||
@@ -21,7 +21,7 @@ export { isJsonValue } 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 } from './surface.ts'
|
||||
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType, validateSurfaceProvenance } from './surface.ts'
|
||||
export { isToolPairingBalanced } from './tool-pairing.ts'
|
||||
export { applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from './request-header.ts'
|
||||
|
||||
|
||||
@@ -81,6 +81,51 @@ export interface SurfaceFoldResult {
|
||||
replacements: SurfaceFoldReplacement[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param shadowedSeqs - surface nodes directly removed by this event.
|
||||
* @returns the first contract violation, or `undefined` when provenance is valid.
|
||||
*/
|
||||
export function validateSurfaceProvenance(
|
||||
event: SessionEvent,
|
||||
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)`
|
||||
}
|
||||
if (sources !== undefined && !Array.isArray(sources)) {
|
||||
return `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'
|
||||
}
|
||||
|
||||
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)}`
|
||||
}
|
||||
if (source >= event.seq) {
|
||||
return `sourceEventSeqs must reference earlier events: ${source} >= current seq ${event.seq}`
|
||||
}
|
||||
if (!knownSeqs.has(source)) return `sourceEventSeqs references unknown seq ${source}`
|
||||
}
|
||||
|
||||
const sourceSet = new Set(sources ?? [])
|
||||
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 undefined
|
||||
}
|
||||
|
||||
/** Mutable state shared by the incremental manager and the full-log fold. */
|
||||
interface SurfaceFoldState {
|
||||
nodes: SurfaceNode[]
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
|
||||
import { Session, SessionId, foldSurface, isSurfaceEligibleType, isSurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
Session,
|
||||
SessionId,
|
||||
foldSurface,
|
||||
isSurfaceEligibleType,
|
||||
isSurfaceEvent,
|
||||
validateSurfaceProvenance,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Build a minimal session with turn boundaries and a single user message. */
|
||||
@@ -13,6 +20,59 @@ function surfaceSession(): Session {
|
||||
return s
|
||||
}
|
||||
|
||||
function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent {
|
||||
return {
|
||||
type: 'user/message',
|
||||
seq,
|
||||
time: seq,
|
||||
data: { content: [], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
sourceEventSeqs,
|
||||
} as unknown as SessionEvent
|
||||
}
|
||||
|
||||
describe('validateSurfaceProvenance', () => {
|
||||
it('accepts absent or valid provenance and complete replacement coverage', () => {
|
||||
expect(validateSurfaceProvenance(provenanceEvent(0, undefined), new Set()))
|
||||
.toBeUndefined()
|
||||
expect(validateSurfaceProvenance(provenanceEvent(2, [0, 1]), new Set([0, 1]), [1]))
|
||||
.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects provenance on a non-surface event', () => {
|
||||
const event = {
|
||||
type: 'turn/start',
|
||||
seq: 1,
|
||||
time: 1,
|
||||
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/)
|
||||
})
|
||||
|
||||
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 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(
|
||||
provenanceEvent(seq, sources),
|
||||
knownSeqs,
|
||||
shadowedSeqs,
|
||||
)).toMatch(expected)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('SurfaceManager', () => {
|
||||
it('shares exact nodes and nested replacement ranges with foldSurface', () => {
|
||||
const s = new Session(SessionId('shared-fold'))
|
||||
|
||||
Reference in New Issue
Block a user