Avoid retaining processed session seqs
This commit is contained in:
@@ -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. 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- `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.
|
- `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 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.
|
- `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`)
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ export class Session {
|
|||||||
* (BigInt, function, symbol, undefined, negative zero, non-finite number,
|
* (BigInt, function, symbol, undefined, negative zero, non-finite number,
|
||||||
* circular reference, sparse array, or an exotic object such as
|
* circular reference, sparse array, or an exotic object such as
|
||||||
* Map/Set/Date/class instance), or when the candidate violates the
|
* 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
|
* earlier provenance, positional replacement validity, and complete
|
||||||
* shadowed-node coverage). One recursive pass reads, validates, and
|
* shadowed-node coverage). One recursive pass reads, validates, and
|
||||||
* copies each nested value once, so a stateful getter cannot supply one value
|
* copies each nested value once, so a stateful getter cannot supply one value
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ export interface SurfaceFoldResult {
|
|||||||
interface SurfaceFoldState {
|
interface SurfaceFoldState {
|
||||||
nodes: SurfaceNode[]
|
nodes: SurfaceNode[]
|
||||||
nodeBySeq: Map<number, SurfaceNode>
|
nodeBySeq: Map<number, SurfaceNode>
|
||||||
knownSeqs: Set<number>
|
|
||||||
replaceGeneration: number
|
replaceGeneration: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +105,6 @@ function createFoldState(replaceGeneration = 0): SurfaceFoldState {
|
|||||||
return {
|
return {
|
||||||
nodes: [],
|
nodes: [],
|
||||||
nodeBySeq: new Map(),
|
nodeBySeq: new Map(),
|
||||||
knownSeqs: new Set(),
|
|
||||||
replaceGeneration,
|
replaceGeneration,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,7 +161,6 @@ function surfaceEventOf(event: SessionEvent): SurfaceEvent | undefined {
|
|||||||
/** Validate provenance against prior log entries and the replacement range. */
|
/** Validate provenance against prior log entries and the replacement range. */
|
||||||
function assertProvenance(
|
function assertProvenance(
|
||||||
event: SurfaceEvent,
|
event: SurfaceEvent,
|
||||||
knownSeqs: ReadonlySet<number>,
|
|
||||||
shadowedSeqs: readonly number[],
|
shadowedSeqs: readonly number[],
|
||||||
): void {
|
): void {
|
||||||
const sources = event.sourceEventSeqs
|
const sources = event.sourceEventSeqs
|
||||||
@@ -178,9 +175,6 @@ function assertProvenance(
|
|||||||
if (source >= event.seq) {
|
if (source >= event.seq) {
|
||||||
throw new Error(`sourceEventSeqs must reference earlier events: ${source} >= current seq ${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))
|
const missing = shadowedSeqs.filter(seq => !sourceSet.has(seq))
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
@@ -213,16 +207,23 @@ function replacementRange(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate one event and prepare its atomic fold transition. */
|
/** Validate one event at its replay boundary and prepare its atomic fold transition. */
|
||||||
function planSurfaceEvent(state: SurfaceFoldState, event: SessionEvent): SurfacePlan | undefined {
|
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)
|
const surfaceEvent = surfaceEventOf(event)
|
||||||
if (surfaceEvent === undefined) return
|
if (surfaceEvent === undefined) return
|
||||||
if (surfaceEvent.surfaceOp === 'append') {
|
if (surfaceEvent.surfaceOp === 'append') {
|
||||||
assertProvenance(surfaceEvent, state.knownSeqs, [])
|
assertProvenance(surfaceEvent, [])
|
||||||
return { kind: 'append', seq: event.seq }
|
return { kind: 'append', seq: event.seq }
|
||||||
}
|
}
|
||||||
const range = replacementRange(state, surfaceEvent.surfaceOp)
|
const range = replacementRange(state, surfaceEvent.surfaceOp)
|
||||||
assertProvenance(surfaceEvent, state.knownSeqs, range.shadowedSeqs)
|
assertProvenance(surfaceEvent, range.shadowedSeqs)
|
||||||
return {
|
return {
|
||||||
kind: 'replace',
|
kind: 'replace',
|
||||||
seq: event.seq,
|
seq: event.seq,
|
||||||
@@ -257,8 +258,9 @@ function replaceSurface(state: SurfaceFoldState, plan: SurfaceReplacePlan): void
|
|||||||
function applySurfaceEvent(
|
function applySurfaceEvent(
|
||||||
state: SurfaceFoldState,
|
state: SurfaceFoldState,
|
||||||
event: SessionEvent,
|
event: SessionEvent,
|
||||||
|
expectedSeq: number,
|
||||||
): SurfaceFoldReplacement | undefined {
|
): SurfaceFoldReplacement | undefined {
|
||||||
const plan = planSurfaceEvent(state, event)
|
const plan = planSurfaceEvent(state, event, expectedSeq)
|
||||||
if (plan?.kind === 'append') {
|
if (plan?.kind === 'append') {
|
||||||
const tail = state.nodes.at(-1)
|
const tail = state.nodes.at(-1)
|
||||||
const node: SurfaceNode = { seq: plan.seq, prev: tail?.seq ?? null, next: null }
|
const node: SurfaceNode = { seq: plan.seq, prev: tail?.seq ?? null, next: null }
|
||||||
@@ -268,7 +270,6 @@ function applySurfaceEvent(
|
|||||||
} else if (plan?.kind === 'replace') {
|
} else if (plan?.kind === 'replace') {
|
||||||
replaceSurface(state, plan)
|
replaceSurface(state, plan)
|
||||||
}
|
}
|
||||||
state.knownSeqs.add(event.seq)
|
|
||||||
if (plan?.kind !== 'replace') return
|
if (plan?.kind !== 'replace') return
|
||||||
return {
|
return {
|
||||||
seq: plan.seq,
|
seq: plan.seq,
|
||||||
@@ -287,14 +288,15 @@ function applySurfaceEvent(
|
|||||||
* @param events - session events in contiguous seq order.
|
* @param events - session events in contiguous seq order.
|
||||||
* @returns the current surface and every positional replacement.
|
* @returns the current surface and every positional replacement.
|
||||||
* @throws when any event violates the unified surface contract: metadata must
|
* @throws when any event violates the unified surface contract: metadata must
|
||||||
* be well shaped and type-eligible, provenance must name unique known earlier
|
* be well shaped and type-eligible, event seqs must be contiguous, provenance
|
||||||
* events, and a positional replacement must name and cite its complete range.
|
* must name unique earlier events, and a positional replacement must name and
|
||||||
|
* cite its complete range.
|
||||||
*/
|
*/
|
||||||
export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult {
|
export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult {
|
||||||
const state = createFoldState()
|
const state = createFoldState()
|
||||||
const replacements: SurfaceFoldReplacement[] = []
|
const replacements: SurfaceFoldReplacement[] = []
|
||||||
for (const event of events) {
|
for (const [index, event] of events.entries()) {
|
||||||
const replacement = applySurfaceEvent(state, event)
|
const replacement = applySurfaceEvent(state, event, index)
|
||||||
if (replacement !== undefined) replacements.push(replacement)
|
if (replacement !== undefined) replacements.push(replacement)
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -326,7 +328,7 @@ export class SurfaceManager {
|
|||||||
*/
|
*/
|
||||||
validateNext(event: SessionEvent): void {
|
validateNext(event: SessionEvent): void {
|
||||||
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
|
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.
|
// Index is bounded by i < this.log.length — never undefined.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const event = this.log[i]!
|
const event = this.log[i]!
|
||||||
applySurfaceEvent(this._state, event)
|
applySurfaceEvent(this._state, event, i)
|
||||||
this._lastProcessedSeq = i
|
this._lastProcessedSeq = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ describe('foldSurface provenance', () => {
|
|||||||
['a fractional number', [provenanceEvent(0, [0.5])], /non-negative safe integers/],
|
['a fractional number', [provenanceEvent(0, [0.5])], /non-negative safe integers/],
|
||||||
['a negative number', [provenanceEvent(0, [-1])], /non-negative safe integers/],
|
['a negative number', [provenanceEvent(0, [-1])], /non-negative safe integers/],
|
||||||
['a self reference', [provenanceEvent(0, [0])], /must reference earlier events/],
|
['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', [
|
['incomplete replacement coverage', [
|
||||||
provenanceEvent(0, undefined),
|
provenanceEvent(0, undefined),
|
||||||
provenanceEvent(1, undefined),
|
provenanceEvent(1, undefined),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
`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`.
|
`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`.
|
||||||
|
|
||||||
|
|||||||
@@ -520,7 +520,8 @@ describe('surface contract under the invariants composition', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('accepts sourceEventSeqs referencing a valid earlier event', async () => {
|
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 { ctx } = await setup()
|
||||||
const session = ctx.sessions.create()
|
const session = ctx.sessions.create()
|
||||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
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/)
|
}).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 () => {
|
it('rejects a replace whose start is positioned after its end on the surface', async () => {
|
||||||
const { ctx } = await setup()
|
const { ctx } = await setup()
|
||||||
const session = ctx.sessions.create()
|
const session = ctx.sessions.create()
|
||||||
|
|||||||
Reference in New Issue
Block a user