diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 608046d053..055a66b926 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -359,7 +359,7 @@ export interface Config { } ``` -Source: [`packages/support/invariants/src/index.ts:45`](../packages/support/invariants/src/index.ts) +Source: [`packages/support/invariants/src/index.ts:52`](../packages/support/invariants/src/index.ts) ## `@deepseek-ai/dsh-llm-deepseek` 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 cb09531fc6..fa2f7a7e33 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: 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 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. 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 99be514fe9..db3952b1a3 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -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`) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index ebbf28d63e..2357649eb6 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.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' diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 3e4ce6d89c..8124f05fe6 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.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, + 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() + 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[] diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 1260b450a4..fe471c29ad 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -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')) diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index f85ade5984..003fc9a839 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 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 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. `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 c7cfd89b9a..8327877ee3 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, isSurfaceEligibleType } from '@deepseek-ai/dsh-session' +import { foldSurface, validateSurfaceProvenance } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import { SessionQueryError } from './config.ts' import type { @@ -51,7 +51,21 @@ export function traceEventLog( } const analysis = analyzeEventLog(sessionId, events) - validateProvenance(events, analysis.replacedEventSeqs) + const knownSeqs = new Set() + for (const event of events) { + const violation = validateSurfaceProvenance( + event, + knownSeqs, + analysis.replacedEventSeqs.get(event.seq), + ) + if (violation !== undefined) { + throw new SessionQueryError( + `invalid session provenance: ${violation}`, + 'SESSION_QUERY_INVALID_PROVENANCE', + ) + } + knownSeqs.add(event.seq) + } const replacementChain: number[] = [] let replacement = analysis.replacedBy.get(seq) @@ -189,73 +203,6 @@ function analyzeEventLog( } } -function validateProvenance( - events: readonly SessionEvent[], - replacedEventSeqs: ReadonlyMap, -): void { - for (const event of events) { - const sources = rawEventSources(event) - if (sources === undefined) continue - if (!isSurfaceEligibleType(event.type)) { - throw new SessionQueryError( - `invalid session provenance: non-surface event at seq ${event.seq} carries sourceEventSeqs`, - 'SESSION_QUERY_INVALID_PROVENANCE', - ) - } - if (!Array.isArray(sources) || sources.length === 0) { - throw new SessionQueryError( - `invalid session provenance: event at seq ${event.seq} has an empty or invalid sourceEventSeqs`, - 'SESSION_QUERY_INVALID_PROVENANCE', - ) - } - const unique = new Set() - for (const source of sources as unknown[]) { - if (unique.has(source)) { - throw new SessionQueryError( - `invalid session provenance: event at seq ${event.seq} repeats source seq ${String(source)}`, - 'SESSION_QUERY_INVALID_PROVENANCE', - ) - } - unique.add(source) - if ( - typeof source !== 'number' - || !Number.isInteger(source) - || source < 0 - || source >= event.seq - || events[source]?.seq !== source - ) { - throw new SessionQueryError( - `invalid session provenance: event at seq ${event.seq} references unknown or non-earlier source seq ${String(source)}`, - 'SESSION_QUERY_INVALID_PROVENANCE', - ) - } - } - } - - for (const [replacementSeq, removedSeqs] of replacedEventSeqs) { - // Canonical logs guarantee events[i].seq === i, and the fold reports only - // replacement events from this input log. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const replacement = events[replacementSeq]! - const sources = rawEventSources(replacement) - if (!Array.isArray(sources)) { - throw new SessionQueryError( - `invalid session provenance: replacement at seq ${replacementSeq} omits its shadowed surface sources`, - 'SESSION_QUERY_INVALID_PROVENANCE', - ) - } - const sourceSet = new Set(sources as unknown[]) - for (const removedSeq of removedSeqs) { - if (!sourceSet.has(removedSeq)) { - throw new SessionQueryError( - `invalid session provenance: replacement at seq ${replacementSeq} omits shadowed surface seq ${removedSeq}`, - 'SESSION_QUERY_INVALID_PROVENANCE', - ) - } - } - } -} - function rawEventSources(event: SessionEvent): unknown { return (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs } diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 94c2682f9d..95ea6261ee 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -31,6 +31,7 @@ await ctx.plugin(Invariants, { freeze: false }) // assert contract, don't freez 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. - **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 8147a6deb6..fb56519906 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -7,7 +7,8 @@ * `session/event`, and `agent/status`. It is **off in production**: enable it * in tests and the demos, where a contract violation should be a loud failure, * not a subtle one. It doubles as executable documentation of the event - * taxonomy: the assertions below ARE the contract. + * taxonomy: these assertions and the shared session validators they invoke + * are the contract. * * Why runtime assertions instead of compile-time deep-readonly types? See * the dev-invariants RFC. Briefly: a `DeepReadonly` is high type-noise across @@ -23,7 +24,13 @@ import type { Context } from 'cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { CallId, GenerateOptions } from '@deepseek-ai/dsh-llm' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' -import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' +import { + Session, + SessionId, + foldRequestHeader, + isSurfaceEligibleType, + validateSurfaceProvenance, +} from '@deepseek-ai/dsh-session' import type { SessionEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' export const name = 'invariants' @@ -121,71 +128,50 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void { trace.lastSeq = event.seq // --- Surface invariants --- - // Surface metadata (sourceEventSeqs, surfaceOp) is only valid on - // surface-eligible event types. The compiler enforces this at append() - // call sites; this runtime check catches casts and persisted data. - const SURFACE_TYPES = new Set(['user/message', 'assistant/message', 'tool/result', 'context/message', 'steering/message']) // Cast to surface-eligible event type so we can access surfaceOp and // sourceEventSeqs (optional on SessionEvent, mandatory on SurfaceEvent). // 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 (!SURFACE_TYPES.has(event.type)) { - if (se.sourceEventSeqs !== undefined) { - throw new InvariantError(`${event.type} cannot carry sourceEventSeqs (non-surface event)`) - } - if (se.surfaceOp !== undefined) { - throw new InvariantError(`${event.type} cannot carry surfaceOp (non-surface event)`) - } - } - if (se.sourceEventSeqs !== undefined) { - if (se.sourceEventSeqs.length === 0) { - throw new InvariantError('sourceEventSeqs must not be empty when present') - } - const unique = new Set(se.sourceEventSeqs) - if (unique.size !== se.sourceEventSeqs.length) { - throw new InvariantError('sourceEventSeqs must not contain duplicates') - } - for (const ref of se.sourceEventSeqs) { - if (ref >= event.seq) { - throw new InvariantError(`sourceEventSeqs must reference earlier events: ${ref} >= current seq ${event.seq}`) - } - if (!trace.knownSeqs.has(ref)) { - throw new InvariantError(`sourceEventSeqs references unknown seq ${ref}`) - } - } + if (!isSurfaceEligibleType(event.type) && se.surfaceOp !== undefined) { + throw new InvariantError(`${event.type} cannot carry surfaceOp (non-surface event)`) } + // Fold this event into the tracked surface linked list, validating the // replace contract as we go. `append` adds a tail node; `replace` shadows a // positional range — every shadowed node must appear in sourceEventSeqs. - if (se.surfaceOp !== undefined) { - if (se.surfaceOp === 'append') { - trace.surface.push(event.seq) - } else { - const { start, end } = se.surfaceOp - const startIdx = trace.surface.indexOf(start) - if (startIdx === -1) { - throw new InvariantError(`surface replace: start seq ${start} is not on the surface`) - } - const endIdx = trace.surface.indexOf(end) - if (endIdx === -1) { - throw new InvariantError(`surface replace: end seq ${end} is not on the surface`) - } - if (startIdx > endIdx) { - throw new InvariantError(`surface replace: start seq ${start} (pos ${startIdx}) is after end seq ${end} (pos ${endIdx}) on the surface`) - } - // Every node the replace shadows (surface positions [startIdx, endIdx] - // inclusive) must appear in sourceEventSeqs — the provenance contract. - const shadowed = trace.surface.slice(startIdx, endIdx + 1) - const recorded = new Set(se.sourceEventSeqs ?? []) - const missing = shadowed.filter(seq => !recorded.has(seq)) - if (missing.length > 0) { - throw new InvariantError(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`) - } - // Apply the replace to the tracked surface: the new node takes the - // range's position so order stays in sync for later replaces. - trace.surface.splice(startIdx, shadowed.length, event.seq) + let replacement: { startIdx: number; shadowed: number[] } | undefined + if (se.surfaceOp !== undefined && se.surfaceOp !== 'append') { + const { start, end } = se.surfaceOp + const startIdx = trace.surface.indexOf(start) + if (startIdx === -1) { + throw new InvariantError(`surface replace: start seq ${start} is not on the surface`) } + const endIdx = trace.surface.indexOf(end) + if (endIdx === -1) { + throw new InvariantError(`surface replace: end seq ${end} is not on the surface`) + } + if (startIdx > endIdx) { + throw new InvariantError(`surface replace: start seq ${start} (pos ${startIdx}) is after end seq ${end} (pos ${endIdx}) on the surface`) + } + replacement = { startIdx, shadowed: trace.surface.slice(startIdx, endIdx + 1) } + } + + const provenanceViolation = validateSurfaceProvenance( + event, + trace.knownSeqs, + replacement?.shadowed, + ) + if (provenanceViolation !== undefined) { + throw new InvariantError(provenanceViolation) + } + + if (se.surfaceOp === 'append') { + trace.surface.push(event.seq) + } else if (replacement !== undefined) { + // The new node takes the replaced range's position so order stays in sync + // for later replacements. + trace.surface.splice(replacement.startIdx, replacement.shadowed.length, event.seq) } // Boundary/step-scoped events have explicit cases; every OTHER event type —