Unify session surface validation
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user