From 84e6f72ef57083cb968fa3473a75bc1caa3b1f79 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 13 Jul 2026 16:05:11 +0800 Subject: [PATCH] fix(session-query): reject misplaced surface ops --- docs/cordis-catalog/services.md | 2 +- .../2026-07-13-session-query-tracing.md | 2 +- packages/core/session/README.md | 4 +- packages/core/session/src/index.ts | 57 ++----- packages/core/session/src/surface.ts | 139 +++++++++++++----- packages/core/session/tests/session.spec.ts | 11 +- packages/core/session/tests/surface.spec.ts | 42 ++++-- .../session-query/session-query/README.md | 2 +- .../session-query/src/tracing.ts | 6 +- .../session-query/tests/tracing.spec.ts | 17 +++ packages/support/invariants/README.md | 2 +- packages/support/invariants/src/index.ts | 12 +- 12 files changed, 182 insertions(+), 114 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 20420a96b7..e3f2f8ffd0 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -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` diff --git a/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md b/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md index fa2f7a7e33..ff2e9b7c23 100644 --- a/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md +++ b/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md @@ -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. diff --git a/packages/core/session/README.md b/packages/core/session/README.md index c575777ccd..fc7e6d4300 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -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`) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 87a5cff8a6..88b3c15aff 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.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 - 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, 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 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) { diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 8124f05fe6..c8642d18fc 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -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, +export function validateSurfaceMetadata( + event: Pick & { + surfaceOp?: unknown + sourceEventSeqs?: unknown + }, + knownSeqs?: ReadonlySet, 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 + 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() - for (const source of sources ?? []) { - if (unique.has(source)) return 'sourceEventSeqs must not contain duplicates' + const unique = new Set() + 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 { diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 2204fc9027..fe44de871f 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -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)['op'] === 'replace') throw 'validator failed' + if ((object as Record)['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, diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index fe471c29ad..fa7b1ff3ce 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -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', () => { diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 003fc9a839..2dcdec085e 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -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`. diff --git a/packages/session-query/session-query/src/tracing.ts b/packages/session-query/session-query/src/tracing.ts index 8327877ee3..09d3ed65f6 100644 --- a/packages/session-query/session-query/src/tracing.ts +++ b/packages/session-query/session-query/src/tracing.ts @@ -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() 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', ) } diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index 8dd67e4893..0a8b8e6da1 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -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])] }]) diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 8e19cfd79c..2fe2db1f6c 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -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. diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 9a3c67b97e..b2fa15080f 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -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 - 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 —