瀏覽代碼

feat(invariants): enforce replace provenance — sourceEventSeqs must cover every shadowed surface node

Hypatia May 3 月之前
父節點
當前提交
358ae02c56

+ 1 - 1
docs/rfc/implemented/architecture/2026-06-18-session-surface.md

@@ -49,7 +49,7 @@ The `repair.ts` module synthesizes `tool/result` closers for orphaned tool calls
 
 ### Invariants
 
-The dev-mode invariants plugin validates: `sourceEventSeqs` references (non-empty, no duplicates, references earlier events, references known seqs) and `surfaceOp` (replace start ≤ end).
+The dev-mode invariants plugin validates: `sourceEventSeqs` references (non-empty, no duplicates, references earlier events, references known seqs) and `surfaceOp` (replace `start ≤ end`, both endpoints are on the tracked surface, the range is non-reversed in surface position, and `sourceEventSeqs` includes every node the range shadows).
 
 ## Consequences
 

+ 41 - 3
packages/support/invariants/src/index.ts

@@ -69,6 +69,13 @@ interface SessionTrace {
   pendingCalls: Set<CallId>
   /** Every seq seen so far — validates `sourceEventSeqs` references. */
   knownSeqs: Set<number>
+  /**
+   * The seqs currently on the surface linked list, in linked-list order
+   * (head to tail). A replace reorders this relative to seq order (the new
+   * node takes the replaced range's position), so range validation is
+   * positional, not by seq comparison.
+   */
+  surface: number[]
 }
 
 /**
@@ -147,9 +154,39 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
       }
     }
   }
-  if (se.surfaceOp !== undefined && typeof se.surfaceOp !== 'string') {
-    if (se.surfaceOp.start > se.surfaceOp.end) {
-      throw new InvariantError(`surface replace: start ${se.surfaceOp.start} must be <= end ${se.surfaceOp.end}`)
+  // Fold this event into the tracked surface linked list, validating the
+  // replace contract as we go. `append` adds a tail node; `replace` shadows a
+  // positional range — every shadowed node must appear in sourceEventSeqs.
+  if (se.surfaceOp !== undefined) {
+    if (se.surfaceOp === 'append') {
+      trace.surface.push(event.seq)
+    } else {
+      const { start, end } = se.surfaceOp
+      if (start > end) {
+        throw new InvariantError(`surface replace: start ${start} must be <= end ${end}`)
+      }
+      const startIdx = trace.surface.indexOf(start)
+      if (startIdx === -1) {
+        throw new InvariantError(`surface replace: start seq ${start} is not on the surface`)
+      }
+      const endIdx = trace.surface.indexOf(end)
+      if (endIdx === -1) {
+        throw new InvariantError(`surface replace: end seq ${end} is not on the surface`)
+      }
+      if (startIdx > endIdx) {
+        throw new InvariantError(`surface replace: start seq ${start} (pos ${startIdx}) is after end seq ${end} (pos ${endIdx}) on the surface`)
+      }
+      // Every node the replace shadows (surface positions [startIdx, endIdx]
+      // inclusive) must appear in sourceEventSeqs — the provenance contract.
+      const shadowed = trace.surface.slice(startIdx, endIdx + 1)
+      const recorded = new Set(se.sourceEventSeqs ?? [])
+      const missing = shadowed.filter(seq => !recorded.has(seq))
+      if (missing.length > 0) {
+        throw new InvariantError(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`)
+      }
+      // Apply the replace to the tracked surface: the new node takes the
+      // range's position so order stays in sync for later replaces.
+      trace.surface.splice(startIdx, shadowed.length, event.seq)
     }
   }
 
@@ -288,6 +325,7 @@ export function apply(ctx: Context, config: Config = {}): void {
     nextStep: 1,
     pendingCalls: new Set(),
     knownSeqs: new Set(),
+    surface: [],
   })
 
   /** Build (or rebuild) a session's trace by replaying its whole log; freeze it. */

+ 90 - 0
packages/support/invariants/tests/invariants.spec.ts

@@ -542,6 +542,96 @@ describe('surface invariants', () => {
     }).toThrow(/must be <= end/)
   })
 
+  it('rejects a replace whose sourceEventSeqs omits a shadowed surface node', async () => {
+    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 })
+    session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
+    session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3
+    // Replace shadows surface nodes [2, 3] but records provenance for only [2].
+    expect(() => {
+      session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'sum' }] }, { surfaceOp: { op: 'replace', start: 2, end: 3 }, sourceEventSeqs: [2] })
+    }).toThrow(/must include every shadowed surface node; missing 3/)
+  })
+
+  it('accepts a replace whose sourceEventSeqs covers every shadowed surface node', async () => {
+    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 })
+    session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
+    session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3
+    expect(() => {
+      session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'sum' }] }, { surfaceOp: { op: 'replace', start: 2, end: 3 }, sourceEventSeqs: [2, 3] })
+    }).not.toThrow()
+  })
+
+  it('rejects a replace naming a start seq that is not on the surface', async () => {
+    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 })
+    session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
+    // seq 1 (step/start) is a real earlier event but never entered the surface.
+    expect(() => {
+      session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] })
+    }).toThrow(/start seq 1 is not on the surface/)
+  })
+
+  it('rejects a replace naming an end seq that is not on the surface', async () => {
+    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 })
+    session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
+    // start (2) is on the surface but end (99) never entered it.
+    expect(() => {
+      session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 99 }, sourceEventSeqs: [2] })
+    }).toThrow(/end seq 99 is not on the surface/)
+  })
+
+  it('rejects a replace whose range is reversed in surface position after a prior replace reordered it', async () => {
+    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 })
+    session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
+    session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3
+    // Replace node 2 (position 0) with seq 4 — surface is now [4, 3], so seq 4
+    // precedes seq 3 in linked-list order even though 4 > 3 numerically.
+    session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] }) // seq 4
+    // A replace with start=3, end=4 passes the seq check (3 <= 4) but is
+    // reversed positionally (3 is at pos 1, 4 is at pos 0).
+    expect(() => {
+      session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 3, end: 4 }, sourceEventSeqs: [3, 4] }) // seq 5
+    }).toThrow(/is after end seq 4 .* on the surface/)
+  })
+
+  it('rejects a replace that omits sourceEventSeqs entirely', async () => {
+    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 })
+    session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
+    // A replace with no sourceEventSeqs records no provenance for the node it shadows.
+    expect(() => {
+      session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 2 } })
+    }).toThrow(/must include every shadowed surface node; missing 2/)
+  })
+
+  it('catches an incomplete-provenance replace on the load/seed path', async () => {
+    const { ctx } = await setup({ freeze: false })
+    const badSeed = [
+      { type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
+      { type: 'step/start' as const, seq: 1, time: 0, data: { turn: 1, step: 1 } },
+      { type: 'user/message' as const, seq: 2, time: 0, data: { content: [{ type: 'text' as const, text: 'a' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const },
+      { type: 'user/message' as const, seq: 3, time: 0, data: { content: [{ type: 'text' as const, text: 'b' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const },
+      { type: 'assistant/message' as const, seq: 4, time: 0, data: { turn: 1, step: 1, content: [{ type: 'text' as const, text: 'sum' }] }, surfaceOp: { op: 'replace' as const, start: 2, end: 3 }, sourceEventSeqs: [2] },
+    ]
+    expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(/must include every shadowed surface node; missing 3/)
+  })
+
   it('rejects sourceEventSeqs on a non-surface event', async () => {
     const { ctx } = await setup()
     const session = ctx.sessions.create()