fix(session-query): reject markerless surface events

This commit is contained in:
Hypatia May
2026-07-12 10:09:47 +08:00
parent 03ce8bfea3
commit bea27efc4b
4 changed files with 34 additions and 2 deletions

View File

@@ -47,7 +47,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
- `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 canonical surface transitions into detached current nodes and actual replacement ranges. `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.
- `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`)

View File

@@ -102,7 +102,10 @@ function applySurfaceEvent(
state: SurfaceFoldState,
event: SessionEvent,
): SurfaceFoldReplacement | undefined {
if (!isSurfaceEvent(event)) return
if (!isSurfaceEligibleType(event.type)) return
if (!isSurfaceEvent(event)) {
throw new Error(`surface event "${event.type}" (seq ${event.seq}) carries no surfaceOp marker`)
}
if (event.surfaceOp === 'append') {
const tail = state.nodes.length > 0 ? state.nodes[state.nodes.length - 1] : undefined
@@ -167,6 +170,8 @@ function replaceSurface(
* models cannot disagree with `deriveMessages()` about replacement ranges.
* @param events - session events in contiguous seq order.
* @returns the current surface and every positional replacement.
* @throws when a surface-eligible event lacks its mandatory `surfaceOp`, or a
* replacement names nodes that are absent or reversed on the current surface.
*/
export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult {
const state = createFoldState()

View File

@@ -55,6 +55,18 @@ describe('SurfaceManager', () => {
expect(() => s.surface.nodes).toThrow(/start seq 42 not found/)
})
it('foldSurface rejects a surface-eligible event without its mandatory marker', () => {
const malformed: SessionEvent = {
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'hidden' }], source: { kind: 'user' } },
}
expect(() => foldSurface([malformed]))
.toThrow(/surface event "user\/message" \(seq 0\) carries no surfaceOp marker/)
})
it('rebuilds a linked list from surfaceOp: append markers', () => {
const s = surfaceSession()
const nodes = s.surface.nodes

View File

@@ -232,6 +232,21 @@ describe('session-query exact reads', () => {
await expect(ctx.sessionQuery.listEvents(session.id))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
const persisted = header('bad-persisted-surface')
TestPersistence.reset([{
meta: persisted,
events: [{
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'hidden' }], source: { kind: 'user' } },
}],
}])
const persistence = await ctx.plugin(TestPersistence)
await expect(ctx.sessionQuery.listEvents(persisted.id))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
await persistence.dispose()
const direct = new Context()
await direct.plugin(SessionStore)
expect(new SessionQueryService(direct)).toBeInstanceOf(SessionQueryService)