diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 4de99ad961..99be514fe9 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -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`) diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 112faf0bd4..3e4ce6d89c 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -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() diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 071bf1af86..1260b450a4 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -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 diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 29b863b678..b449e59bb5 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -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)