refactor(session): centralize surface provenance validation
This commit is contained in:
@@ -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`
|
## `@deepseek-ai/dsh-llm-deepseek`
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Session relationships are encoded across immutable headers, positional surface o
|
|||||||
|
|
||||||
## Validation boundary
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- `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.
|
- `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.
|
- `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.
|
- `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`)
|
### Request-header reconstruction (`request-header.ts`)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export { isJsonValue } from './json.ts'
|
|||||||
export type { JsonValue } from './json.ts'
|
export type { JsonValue } from './json.ts'
|
||||||
export { interruptedTurnClosers } from './repair.ts'
|
export { interruptedTurnClosers } from './repair.ts'
|
||||||
export type { SurfaceFoldReplacement, SurfaceFoldResult, SurfaceNode } from './surface.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 { isToolPairingBalanced } from './tool-pairing.ts'
|
||||||
export { applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from './request-header.ts'
|
export { applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from './request-header.ts'
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,51 @@ export interface SurfaceFoldResult {
|
|||||||
replacements: SurfaceFoldReplacement[]
|
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. */
|
/** Mutable state shared by the incremental manager and the full-log fold. */
|
||||||
interface SurfaceFoldState {
|
interface SurfaceFoldState {
|
||||||
nodes: SurfaceNode[]
|
nodes: SurfaceNode[]
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
|
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'
|
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||||
|
|
||||||
/** Build a minimal session with turn boundaries and a single user message. */
|
/** Build a minimal session with turn boundaries and a single user message. */
|
||||||
@@ -13,6 +20,59 @@ function surfaceSession(): Session {
|
|||||||
return s
|
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', () => {
|
describe('SurfaceManager', () => {
|
||||||
it('shares exact nodes and nested replacement ranges with foldSurface', () => {
|
it('shares exact nodes and nested replacement ranges with foldSurface', () => {
|
||||||
const s = new Session(SessionId('shared-fold'))
|
const s = new Session(SessionId('shared-fold'))
|
||||||
|
|||||||
@@ -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.
|
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`.
|
`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`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** One-shot session-lineage and event-relationship tracing helpers. */
|
/** 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 type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import { SessionQueryError } from './config.ts'
|
import { SessionQueryError } from './config.ts'
|
||||||
import type {
|
import type {
|
||||||
@@ -51,7 +51,21 @@ export function traceEventLog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const analysis = analyzeEventLog(sessionId, events)
|
const analysis = analyzeEventLog(sessionId, events)
|
||||||
validateProvenance(events, analysis.replacedEventSeqs)
|
const knownSeqs = new Set<number>()
|
||||||
|
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[] = []
|
const replacementChain: number[] = []
|
||||||
let replacement = analysis.replacedBy.get(seq)
|
let replacement = analysis.replacedBy.get(seq)
|
||||||
@@ -189,73 +203,6 @@ function analyzeEventLog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateProvenance(
|
|
||||||
events: readonly SessionEvent[],
|
|
||||||
replacedEventSeqs: ReadonlyMap<number, readonly number[]>,
|
|
||||||
): 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<unknown>()
|
|
||||||
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 {
|
function rawEventSources(event: SessionEvent): unknown {
|
||||||
return (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs
|
return (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ await ctx.plugin(Invariants, { freeze: false }) // assert contract, don't freez
|
|||||||
Session log (per session):
|
Session log (per session):
|
||||||
|
|
||||||
- **`seq` strictly increases** — the spine of replay equivalence.
|
- **`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.
|
- **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.
|
- **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.
|
- **chunks belong to an open step** — `step/start` precedes its `assistant/chunk`s.
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
* `session/event`, and `agent/status`. It is **off in production**: enable it
|
* `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,
|
* 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
|
* 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
|
* Why runtime assertions instead of compile-time deep-readonly types? See
|
||||||
* the dev-invariants RFC. Briefly: a `DeepReadonly<SessionEvent>` is high type-noise across
|
* the dev-invariants RFC. Briefly: a `DeepReadonly<SessionEvent>` is high type-noise across
|
||||||
@@ -23,7 +24,13 @@ import type { Context } from 'cordis'
|
|||||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||||
import type { CallId, GenerateOptions } from '@deepseek-ai/dsh-llm'
|
import type { CallId, GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
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'
|
import type { SessionEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
|
||||||
|
|
||||||
export const name = 'invariants'
|
export const name = 'invariants'
|
||||||
@@ -121,71 +128,50 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
|
|||||||
trace.lastSeq = event.seq
|
trace.lastSeq = event.seq
|
||||||
|
|
||||||
// --- Surface invariants ---
|
// --- 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<string>(['user/message', 'assistant/message', 'tool/result', 'context/message', 'steering/message'])
|
|
||||||
// Cast to surface-eligible event type so we can access surfaceOp and
|
// Cast to surface-eligible event type so we can access surfaceOp and
|
||||||
// sourceEventSeqs (optional on SessionEvent, mandatory on SurfaceEvent).
|
// sourceEventSeqs (optional on SessionEvent, mandatory on SurfaceEvent).
|
||||||
// SurfaceEvent's mandatory surfaceOp is too strict here — we need to
|
// SurfaceEvent's mandatory surfaceOp is too strict here — we need to
|
||||||
// CHECK whether surface metadata is present, not assume it.
|
// CHECK whether surface metadata is present, not assume it.
|
||||||
const se = event as SessionEvent<SurfaceEventType>
|
const se = event as SessionEvent<SurfaceEventType>
|
||||||
if (!SURFACE_TYPES.has(event.type)) {
|
if (!isSurfaceEligibleType(event.type) && se.surfaceOp !== undefined) {
|
||||||
if (se.sourceEventSeqs !== undefined) {
|
throw new InvariantError(`${event.type} cannot carry surfaceOp (non-surface event)`)
|
||||||
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}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fold this event into the tracked surface linked list, validating the
|
// Fold this event into the tracked surface linked list, validating the
|
||||||
// replace contract as we go. `append` adds a tail node; `replace` shadows a
|
// replace contract as we go. `append` adds a tail node; `replace` shadows a
|
||||||
// positional range — every shadowed node must appear in sourceEventSeqs.
|
// positional range — every shadowed node must appear in sourceEventSeqs.
|
||||||
if (se.surfaceOp !== undefined) {
|
let replacement: { startIdx: number; shadowed: number[] } | undefined
|
||||||
if (se.surfaceOp === 'append') {
|
if (se.surfaceOp !== undefined && se.surfaceOp !== 'append') {
|
||||||
trace.surface.push(event.seq)
|
const { start, end } = se.surfaceOp
|
||||||
} else {
|
const startIdx = trace.surface.indexOf(start)
|
||||||
const { start, end } = se.surfaceOp
|
if (startIdx === -1) {
|
||||||
const startIdx = trace.surface.indexOf(start)
|
throw new InvariantError(`surface replace: start seq ${start} is not on the surface`)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
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 —
|
// Boundary/step-scoped events have explicit cases; every OTHER event type —
|
||||||
|
|||||||
Reference in New Issue
Block a user