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 9ebb8ee961..f16f543ac5 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. Both event listing and tracing then use `dsh-session`'s one-pass surface fold, which accepts or rejects the loaded log as a whole: 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 and cites all surface nodes it removes. Every contract failure uses `SESSION_QUERY_INVALID_SURFACE`; there is no weaker classification-only surface standard. +Event tracing checks target existence before surface analysis. Both event listing and tracing then use `dsh-session`'s one-pass surface fold, which accepts or rejects the loaded log as a whole: event seqs are zero-based and contiguous, surface markers obey event-type eligibility, provenance belongs only to surface event types, present arrays are nonempty and duplicate-free, every source is an earlier seq, and every positional replacement names and cites all surface nodes it removes. Every contract failure uses `SESSION_QUERY_INVALID_SURFACE`; there is no weaker classification-only surface standard. 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 7632212621..18eb6ebd82 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -49,7 +49,7 @@ 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 one canonical surface contract into detached current nodes and actual replacement ranges. The same pass rejects misplaced or malformed metadata, empty or duplicate provenance, unknown or non-earlier sources, invalid positional ranges, and replacements that fail to cite every shadowed surface node; `SurfaceManager` shares the atomic transition while retaining its incremental cache. +- `foldSurface(events)` — replay the one canonical surface contract into detached current nodes and actual replacement ranges. The same pass rejects non-contiguous event seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, and replacements that fail to cite every shadowed surface node; `SurfaceManager` shares the atomic transition while retaining its incremental cache. - `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 fc3a7fcd29..3c881882ba 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -328,7 +328,7 @@ export class Session { * (BigInt, function, symbol, undefined, negative zero, non-finite number, * circular reference, sparse array, or an exotic object such as * Map/Set/Date/class instance), or when the candidate violates the - * canonical surface contract (marker shape and eligibility, unique known + * canonical surface contract (marker shape and eligibility, unique * earlier provenance, positional replacement validity, and complete * shadowed-node coverage). One recursive pass reads, validates, and * copies each nested value once, so a stateful getter cannot supply one value diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 7e1da76832..0d9e93e961 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -85,7 +85,6 @@ export interface SurfaceFoldResult { interface SurfaceFoldState { nodes: SurfaceNode[] nodeBySeq: Map - knownSeqs: Set replaceGeneration: number } @@ -106,7 +105,6 @@ function createFoldState(replaceGeneration = 0): SurfaceFoldState { return { nodes: [], nodeBySeq: new Map(), - knownSeqs: new Set(), replaceGeneration, } } @@ -163,7 +161,6 @@ function surfaceEventOf(event: SessionEvent): SurfaceEvent | undefined { /** Validate provenance against prior log entries and the replacement range. */ function assertProvenance( event: SurfaceEvent, - knownSeqs: ReadonlySet, shadowedSeqs: readonly number[], ): void { const sources = event.sourceEventSeqs @@ -178,9 +175,6 @@ function assertProvenance( if (source >= event.seq) { throw new Error(`sourceEventSeqs must reference earlier events: ${source} >= current seq ${event.seq}`) } - if (!knownSeqs.has(source)) { - throw new Error(`sourceEventSeqs references unknown seq ${source}`) - } } const missing = shadowedSeqs.filter(seq => !sourceSet.has(seq)) if (missing.length > 0) { @@ -213,16 +207,23 @@ function replacementRange( } } -/** Validate one event and prepare its atomic fold transition. */ -function planSurfaceEvent(state: SurfaceFoldState, event: SessionEvent): SurfacePlan | undefined { +/** Validate one event at its replay boundary and prepare its atomic fold transition. */ +function planSurfaceEvent( + state: SurfaceFoldState, + event: SessionEvent, + expectedSeq: number, +): SurfacePlan | undefined { + if (event.seq !== expectedSeq) { + throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`) + } const surfaceEvent = surfaceEventOf(event) if (surfaceEvent === undefined) return if (surfaceEvent.surfaceOp === 'append') { - assertProvenance(surfaceEvent, state.knownSeqs, []) + assertProvenance(surfaceEvent, []) return { kind: 'append', seq: event.seq } } const range = replacementRange(state, surfaceEvent.surfaceOp) - assertProvenance(surfaceEvent, state.knownSeqs, range.shadowedSeqs) + assertProvenance(surfaceEvent, range.shadowedSeqs) return { kind: 'replace', seq: event.seq, @@ -257,8 +258,9 @@ function replaceSurface(state: SurfaceFoldState, plan: SurfaceReplacePlan): void function applySurfaceEvent( state: SurfaceFoldState, event: SessionEvent, + expectedSeq: number, ): SurfaceFoldReplacement | undefined { - const plan = planSurfaceEvent(state, event) + const plan = planSurfaceEvent(state, event, expectedSeq) if (plan?.kind === 'append') { const tail = state.nodes.at(-1) const node: SurfaceNode = { seq: plan.seq, prev: tail?.seq ?? null, next: null } @@ -268,7 +270,6 @@ function applySurfaceEvent( } else if (plan?.kind === 'replace') { replaceSurface(state, plan) } - state.knownSeqs.add(event.seq) if (plan?.kind !== 'replace') return return { seq: plan.seq, @@ -287,14 +288,15 @@ function applySurfaceEvent( * @param events - session events in contiguous seq order. * @returns the current surface and every positional replacement. * @throws when any event violates the unified surface contract: metadata must - * be well shaped and type-eligible, provenance must name unique known earlier - * events, and a positional replacement must name and cite its complete range. + * be well shaped and type-eligible, event seqs must be contiguous, provenance + * must name unique earlier events, and a positional replacement must name and + * cite its complete range. */ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult { const state = createFoldState() const replacements: SurfaceFoldReplacement[] = [] - for (const event of events) { - const replacement = applySurfaceEvent(state, event) + for (const [index, event] of events.entries()) { + const replacement = applySurfaceEvent(state, event, index) if (replacement !== undefined) replacements.push(replacement) } return { @@ -326,7 +328,7 @@ export class SurfaceManager { */ validateNext(event: SessionEvent): void { if (this._lastProcessedSeq < this.log.length - 1) this._processDelta() - planSurfaceEvent(this._state, event) + planSurfaceEvent(this._state, event, this.log.length) } /** @@ -370,7 +372,7 @@ export class SurfaceManager { // Index is bounded by i < this.log.length — never undefined. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const event = this.log[i]! - applySurfaceEvent(this._state, event) + applySurfaceEvent(this._state, event, i) this._lastProcessedSeq = i } } diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index e1f948f2b1..2fab5075aa 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -62,7 +62,7 @@ describe('foldSurface provenance', () => { ['a fractional number', [provenanceEvent(0, [0.5])], /non-negative safe integers/], ['a negative number', [provenanceEvent(0, [-1])], /non-negative safe integers/], ['a self reference', [provenanceEvent(0, [0])], /must reference earlier events/], - ['an unknown earlier seq', [provenanceEvent(0, undefined), provenanceEvent(2, [1])], /references unknown seq 1/], + ['a non-contiguous event seq', [provenanceEvent(0, undefined), provenanceEvent(2, [1])], /seq 2 is not contiguous; expected 1/], ['incomplete replacement coverage', [ provenanceEvent(0, undefined), provenanceEvent(1, undefined), diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 4f9f8122b4..2be712bd9b 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. -`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`. +`listEvents()` and `traceEvent()` run the same one-pass `dsh-session` surface fold. A loaded log is valid only when event seqs are zero-based and contiguous, surface markers obey event-type eligibility, provenance arrays are nonempty and duplicate-free, references name 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_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`. diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index d5f43d1568..7c83f4571d 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -520,7 +520,8 @@ describe('surface contract under the invariants composition', () => { }) it('accepts sourceEventSeqs referencing a valid earlier event', async () => { - // Positive test: ref < current seq and ref is in knownSeqs → passes. + // Session seqs are contiguous, so every non-negative ref below the current + // seq necessarily names an existing earlier event. const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -540,31 +541,6 @@ describe('surface contract under the invariants composition', () => { }).toThrow(/must reference earlier/) }) - it('rejects sourceEventSeqs referencing unknown seq (gap in event log)', async () => { - // The unknown-seq check fires when a ref passes the "earlier" test but is - // not in the folded log — only possible with a gap in seqs. We create a gap by - // directly manipulating the private log array to skip a seq. - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - // Push a fake event at seq 3 into the internal log, creating a gap at seq 2. - // The canonical surface validator folds the committed delta before checking - // the next append, so it sees this gap. - ;(session as unknown as { log: unknown[] }).log.push({ - type: 'assistant/chunk', - seq: 3, - time: Date.now(), - data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } }, - }) - // Now the log has seqs 0, 1, 3 (gap at 2). Append at what session believes - // is seq 3 (log.length). Reference seq 2: passes earlier (2 < 3) but not - // in knownSeqs ({0, 1, 3} — gap at 2). - expect(() => { - session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [2] }) - }).toThrow(/unknown seq 2/) - }) - it('rejects a replace whose start is positioned after its end on the surface', async () => { const { ctx } = await setup() const session = ctx.sessions.create()