Unify session surface validation

This commit is contained in:
Hypatia May
2026-07-14 13:49:36 +08:00
parent d9714fb30b
commit dbe65e1d13
17 changed files with 327 additions and 405 deletions

View File

@@ -14,9 +14,9 @@ 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 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.
`listEvents()` and `traceEvent()` run the same one-pass `dsh-session` surface fold. A loaded log is valid only when surface markers obey event-type eligibility, provenance arrays are nonempty and duplicate-free, references name known earlier events, and each positional replacement names and cites every surface node it removes; every violation fails with `SESSION_QUERY_INVALID_SURFACE`.
`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`.
`SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_LINEAGE`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`.
## Configuration

View File

@@ -16,7 +16,6 @@ export type SessionQueryErrorCode =
| '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'

View File

@@ -1,7 +1,7 @@
/** One-shot session-lineage and event-relationship tracing helpers. */
import { foldSurface, validateSurfaceMetadata } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import { foldSurface } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId, SurfaceEventType } from '@deepseek-ai/dsh-session'
import { SessionQueryError } from './config.ts'
import type {
SessionEventRecord,
@@ -51,21 +51,6 @@ export function traceEventLog(
}
const analysis = analyzeEventLog(sessionId, events)
const knownSeqs = new Set<number>()
for (const event of events) {
const violation = validateSurfaceMetadata(
event,
knownSeqs,
analysis.replacedEventSeqs.get(event.seq),
)
if (violation !== undefined) {
throw new SessionQueryError(
`invalid session provenance: ${violation.message}`,
'SESSION_QUERY_INVALID_PROVENANCE',
)
}
knownSeqs.add(event.seq)
}
const replacementChain: number[] = []
let replacement = analysis.replacedBy.get(seq)
@@ -143,7 +128,9 @@ export function traceLineage(
children.push(record)
childrenByParent.set(parent, children)
}
for (const children of childrenByParent.values()) children.sort(compareSessionsAscending)
for (const children of childrenByParent.values()) {
children.sort((a, b) => a.header.createdAt - b.header.createdAt || a.header.id.localeCompare(b.header.id))
}
const descendants = buildDescendants(childrenByParent, sessionId)
const common = {
@@ -203,13 +190,8 @@ function analyzeEventLog(
}
}
function rawEventSources(event: SessionEvent): unknown {
return (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs
}
function eventSources(event: SessionEvent): number[] {
const sources = rawEventSources(event)
return Array.isArray(sources) ? sources as number[] : []
return (event as SessionEvent<SurfaceEventType>).sourceEventSeqs ?? []
}
function buildDescendants(
@@ -238,10 +220,6 @@ function buildDescendants(
return descendants
}
function compareSessionsAscending(a: SessionRecord, b: SessionRecord): number {
return a.header.createdAt - b.header.createdAt || a.header.id.localeCompare(b.header.id)
}
function cloneRecord(record: SessionRecord): SessionRecord {
return { ...record, header: structuredClone(record.header) }
}

View File

@@ -110,7 +110,7 @@ describe('session-query exact reads', () => {
session.append(
'assistant/message',
{ turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }] },
{ surfaceOp: { op: 'replace', start: first.seq, end: first.seq } },
{ surfaceOp: { op: 'replace', start: first.seq, end: first.seq }, sourceEventSeqs: [first.seq] },
)
expect((await ctx.sessionQuery.listEvents(session.id)).map(record => record.surface))
@@ -227,11 +227,13 @@ describe('session-query exact reads', () => {
it('turns malformed surfaces and direct invalid config into typed errors', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('bad-surface'))
session.append(
'assistant/message',
{ turn: 1, step: 1, content: [] },
{ surfaceOp: { op: 'replace', start: 9, end: 9 } },
)
;(session as unknown as { log: SessionEvent[] }).log.push({
type: 'assistant/message',
seq: 0,
time: 1,
data: { turn: 1, step: 1, content: [] },
surfaceOp: { op: 'replace', start: 9, end: 9 },
})
await expect(ctx.sessionQuery.listEvents(session.id))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))

View File

@@ -379,7 +379,7 @@ describe('session event tracing', () => {
appendEvent(1),
{ ...appendEvent(2, [0]), surfaceOp: { op: 'replace', start: 1, end: 1 } },
]],
] as const)('rejects invalid whole-log provenance: %s', async (_name, rawEvents) => {
] as const)('rejects an invalid surface log: %s', async (_name, rawEvents) => {
const durable = header('invalid-provenance')
const events = structuredClone(rawEvents) as unknown as SessionEvent[]
TracePersistence.reset([{ meta: durable, events }])
@@ -387,7 +387,7 @@ describe('session event tracing', () => {
await ctx.plugin(TracePersistence)
await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_PROVENANCE'))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
})
it('rejects surfaceOp on a non-surface event as an invalid surface', async () => {
@@ -407,15 +407,13 @@ describe('session event tracing', () => {
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
})
it('keeps listEvents tolerant of malformed provenance alone', async () => {
it('applies the same surface contract to listEvents', async () => {
const durable = header('list-regression')
TracePersistence.reset([{ meta: durable, events: [appendEvent(0), appendEvent(1, [0, 0])] }])
const ctx = await queryContext()
await ctx.plugin(TracePersistence)
await expect(ctx.sessionQuery.listEvents(durable.id)).resolves.toMatchObject([
{ seq: 0, surface: 'current' },
{ seq: 1, surface: 'current' },
])
await expect(ctx.sessionQuery.listEvents(durable.id))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
})
})