refactor(session): centralize surface provenance validation

This commit is contained in:
Hypatia May
2026-07-13 14:46:43 +08:00
parent 7351f07995
commit 75de01f06d
10 changed files with 171 additions and 131 deletions

View File

@@ -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'

View File

@@ -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<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. */
interface SurfaceFoldState {
nodes: SurfaceNode[]