switchroom 0.12.26 → 0.12.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -261,6 +261,7 @@ import { chatKey, chatKeyWithSuffix } from './chat-key.js'
261
261
  // effects.
262
262
  import { shadowEmit } from './inbound-delivery-machine-shadow.js'
263
263
  import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
264
+ import { dispatchEffects, isDispatchEnabled } from './inbound-delivery-machine-dispatch.js'
264
265
  import {
265
266
  buildVaultGrantApprovedInbound,
266
267
  buildVaultGrantDeniedInbound,
@@ -3204,50 +3205,59 @@ const ipcServer: IpcServer = createIpcServer({
3204
3205
  // causing the shadow state to read `bridge_dead` even when the
3205
3206
  // real bridge was healthy, because every recall.py connect+disconnect
3206
3207
  // would flip the state.
3207
- if (client.agentName != null) {
3208
- shadowEmit({ kind: 'bridgeUp', at: Date.now() })
3209
- }
3208
+ const bridgeUpEffects = client.agentName != null
3209
+ ? shadowEmit({ kind: 'bridgeUp', at: Date.now() })
3210
+ : []
3210
3211
  client.send({ type: 'status', status: 'agent_connected' })
3211
3212
 
3212
- // #1150: drain any synthetic inbounds queued for this agent while
3213
- // the bridge was offline. Done BEFORE the boot-card path below so
3214
- // the agent wakes up to its missed wake-ups first, even if the
3215
- // boot card edit happens to be slow. Skip drain when agentName is
3216
- // null (pre-handshake / anonymous client) those clients are
3217
- // bridges that never registered an identity and can't have
3218
- // accumulated buffered inbounds keyed by name.
3213
+ // Phase 2b PR 3a bridgeUp cutover. The state machine's `bridgeUp`
3214
+ // transition returns `redeliverPersistedPermVerdicts`, `drainBuffer`,
3215
+ // and `logTrace` effects. The dispatcher executes them in order:
3216
+ // the perm-verdict drain unblocks any claude turn suspended inside
3217
+ // an MCP permission call; the inbound drain flushes synthetic
3218
+ // inbounds queued while the bridge was offline (#1150) so the agent
3219
+ // wakes up to missed wake-ups before the boot-card path below runs.
3220
+ // Lossless: `redeliverBufferedInbound` re-buffers per-message misses
3221
+ // and `spool.ack` tombstones confirmed deliveries. Skipped when
3222
+ // agentName is null (pre-handshake / anonymous client — those
3223
+ // bridges never registered an identity and can't have accumulated
3224
+ // buffered inbounds keyed by name).
3219
3225
  if (client.agentName != null) {
3220
- const pending = pendingInboundBuffer.drain(client.agentName)
3221
- for (const msg of pending) {
3222
- try {
3223
- client.send(msg)
3224
- // Confirmed delivery to the just-registered live bridge →
3225
- // tombstone the durable spool entry so it isn't boot-replayed
3226
- // again. A throw below leaves it spooled (un-acked) so the
3227
- // idle-drain / escalation path still recovers it — strictly
3228
- // safer than the old log-and-drop.
3229
- inboundSpool?.ack(msg)
3230
- } catch (err) {
3231
- process.stderr.write(
3232
- `telegram gateway: pending-inbound drain failed agent=${client.agentName} ` +
3233
- `source=${msg.meta?.source ?? '-'}: ${(err as Error).message}\n`,
3234
- )
3226
+ if (isDispatchEnabled()) {
3227
+ dispatchEffects(bridgeUpEffects, {
3228
+ selfAgent: client.agentName,
3229
+ ipcServer,
3230
+ pendingInboundBuffer,
3231
+ inboundSpool: inboundSpool ?? null,
3232
+ pendingPermissionBuffer,
3233
+ client,
3234
+ })
3235
+ } else {
3236
+ // Kill-switch fallback: imperative drain (parity with pre-cutover
3237
+ // behavior). Kept for SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0
3238
+ // rollback safety; deleted in PR 4 once the cutover bakes.
3239
+ const pending = pendingInboundBuffer.drain(client.agentName)
3240
+ for (const msg of pending) {
3241
+ try {
3242
+ client.send(msg)
3243
+ inboundSpool?.ack(msg)
3244
+ } catch (err) {
3245
+ process.stderr.write(
3246
+ `telegram gateway: pending-inbound drain failed agent=${client.agentName} ` +
3247
+ `source=${msg.meta?.source ?? '-'}: ${(err as Error).message}\n`,
3248
+ )
3249
+ }
3235
3250
  }
3236
- }
3237
- // PR-3: drain permission verdicts missed while the bridge was
3238
- // offline. A claude turn suspended inside the MCP permission call
3239
- // is unblocked the moment the reconnecting bridge relays the
3240
- // verdict; without this the verdict (incl. the TTL auto-deny) was
3241
- // lost and the turn stayed silent forever.
3242
- const pendingVerdicts = pendingPermissionBuffer.drain(client.agentName)
3243
- for (const ev of pendingVerdicts) {
3244
- try {
3245
- client.send(ev)
3246
- } catch (err) {
3247
- process.stderr.write(
3248
- `telegram gateway: pending-permission drain failed agent=${client.agentName} ` +
3249
- `request=${ev.requestId} behavior=${ev.behavior}: ${(err as Error).message}\n`,
3250
- )
3251
+ const pendingVerdicts = pendingPermissionBuffer.drain(client.agentName)
3252
+ for (const ev of pendingVerdicts) {
3253
+ try {
3254
+ client.send(ev)
3255
+ } catch (err) {
3256
+ process.stderr.write(
3257
+ `telegram gateway: pending-permission drain failed agent=${client.agentName} ` +
3258
+ `request=${ev.requestId} behavior=${ev.behavior}: ${(err as Error).message}\n`,
3259
+ )
3260
+ }
3251
3261
  }
3252
3262
  }
3253
3263
  }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * InboundDeliveryStateMachine — DISPATCH (Phase 2b PR 3a, bridgeUp cutover).
3
+ *
4
+ * Per RFC `docs/rfcs/inbound-delivery-state-machine.md`, the state
5
+ * machine is pure: `transition(state, event) → { state', effects[] }`.
6
+ * The gateway's job is to (a) emit events at the right moments and
7
+ * (b) execute the returned effects against real I/O. This module owns
8
+ * step (b) for the cutover.
9
+ *
10
+ * Scope of THIS PR — bridgeUp only:
11
+ * - drainBuffer → executed
12
+ * - redeliverPersistedPermVerdicts → executed
13
+ * - logTrace → executed
14
+ *
15
+ * Other effects (deliverToBridge, bufferInbound, persistInbound,
16
+ * setTurnStarted, clearTurnStarted, noteOutbound, firePoke,
17
+ * deliverPermVerdict, persistPermVerdict) still flow through their
18
+ * existing imperative paths in `gateway.ts`. The dispatcher logs them
19
+ * as `not-yet-cutover` so a future PR can wire them without grep-and-
20
+ * pray. NEVER silently no-op: the trace is the gate.
21
+ *
22
+ * Kill switch: `SWITCHROOM_DELIVERY_MACHINE_CUTOVER=0` disables
23
+ * dispatcher execution and the gateway falls back to imperative-only.
24
+ * Default is ON — this PR is the cutover.
25
+ */
26
+
27
+ import type {
28
+ Effect,
29
+ InboundMessage as MachineInboundMessage,
30
+ } from './inbound-delivery-machine.js'
31
+ import type { IpcServer, IpcClient } from './ipc-server.js'
32
+ import type { InboundMessage } from './ipc-protocol.js'
33
+ import type { PendingInboundBuffer } from './pending-inbound-buffer.js'
34
+ import { redeliverBufferedInbound } from './pending-inbound-buffer.js'
35
+ import type { InboundSpool } from './inbound-spool.js'
36
+ import type { PendingPermissionBuffer } from './pending-permission-decisions.js'
37
+
38
+ export interface DispatchCtx {
39
+ readonly selfAgent: string
40
+ readonly ipcServer: IpcServer
41
+ readonly pendingInboundBuffer: PendingInboundBuffer
42
+ readonly inboundSpool: InboundSpool | null
43
+ readonly pendingPermissionBuffer: PendingPermissionBuffer
44
+ /** Optional: when set, prefer sending direct to this client (bridgeUp path). */
45
+ readonly client?: IpcClient
46
+ /** Optional log sink — default stderr. Test hook. */
47
+ readonly log?: (line: string) => void
48
+ }
49
+
50
+ const enabled = process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER !== '0'
51
+
52
+ export function isDispatchEnabled(): boolean {
53
+ return enabled
54
+ }
55
+
56
+ /**
57
+ * Execute the effects returned by `transition()`. Pure imperative
58
+ * driver: side-effects only, no machine state held here.
59
+ *
60
+ * Effects are dispatched in the order returned by the machine. The
61
+ * machine guarantees a sensible order (e.g., `setTurnStarted` before
62
+ * `deliverToBridge` so a downstream observer sees the turn marker
63
+ * already up).
64
+ */
65
+ export function dispatchEffects(effects: readonly Effect[], ctx: DispatchCtx): void {
66
+ if (!enabled) return
67
+ for (const effect of effects) {
68
+ dispatchOne(effect, ctx)
69
+ }
70
+ }
71
+
72
+ function dispatchOne(effect: Effect, ctx: DispatchCtx): void {
73
+ const log = ctx.log ?? ((line: string) => process.stderr.write(line))
74
+ switch (effect.kind) {
75
+ case 'drainBuffer': {
76
+ // The bridgeUp drain: flush any synthetic inbounds queued for
77
+ // this agent while the bridge was offline. Mirrors what
78
+ // onClientRegistered did inline pre-cutover (gateway.ts:3219-3236).
79
+ // Lossless: redeliverBufferedInbound re-buffers any per-message
80
+ // miss and tombstones the durable spool entry on confirmed
81
+ // delivery.
82
+ const send = (msg: InboundMessage): boolean => {
83
+ // Prefer the just-registered client when present (bridgeUp
84
+ // path) — that's a direct send. Fall back to ipcServer
85
+ // lookup for non-client-bound drains (turn-complete flush
86
+ // from a future PR).
87
+ if (ctx.client) {
88
+ try {
89
+ ctx.client.send(msg)
90
+ return true
91
+ } catch (err) {
92
+ log(
93
+ `telegram gateway: dispatch drainBuffer client.send threw agent=${ctx.selfAgent} ` +
94
+ `source=${msg.meta?.source ?? '-'}: ${(err as Error).message}\n`,
95
+ )
96
+ return false
97
+ }
98
+ }
99
+ return ctx.ipcServer.sendToAgent(ctx.selfAgent, msg)
100
+ }
101
+ const result = redeliverBufferedInbound(
102
+ ctx.pendingInboundBuffer,
103
+ ctx.selfAgent,
104
+ send,
105
+ ctx.inboundSpool ?? undefined,
106
+ )
107
+ if (result.drained > 0) {
108
+ log(
109
+ `telegram gateway: dispatch drainBuffer agent=${ctx.selfAgent} ` +
110
+ `drained=${result.drained} redelivered=${result.redelivered} ` +
111
+ `rebuffered=${result.rebuffered}\n`,
112
+ )
113
+ }
114
+ return
115
+ }
116
+
117
+ case 'redeliverPersistedPermVerdicts': {
118
+ // Drain permission verdicts missed while the bridge was offline.
119
+ // A claude turn suspended inside the MCP permission call is
120
+ // unblocked the moment the reconnecting bridge relays the
121
+ // verdict. Mirrors the pre-cutover loop at gateway.ts:3242-3252.
122
+ const pending = ctx.pendingPermissionBuffer.drain(ctx.selfAgent)
123
+ let delivered = 0
124
+ let failed = 0
125
+ for (const ev of pending) {
126
+ try {
127
+ if (ctx.client) {
128
+ ctx.client.send(ev)
129
+ } else {
130
+ ctx.ipcServer.sendToAgent(ctx.selfAgent, ev)
131
+ }
132
+ delivered++
133
+ } catch (err) {
134
+ failed++
135
+ log(
136
+ `telegram gateway: dispatch redeliverPerm send threw agent=${ctx.selfAgent} ` +
137
+ `request=${ev.requestId} behavior=${ev.behavior}: ${(err as Error).message}\n`,
138
+ )
139
+ }
140
+ }
141
+ if (pending.length > 0) {
142
+ log(
143
+ `telegram gateway: dispatch redeliverPerm agent=${ctx.selfAgent} ` +
144
+ `delivered=${delivered} failed=${failed}\n`,
145
+ )
146
+ }
147
+ return
148
+ }
149
+
150
+ case 'logTrace': {
151
+ const keyPart = effect.key ? ` key=${effect.key}` : ''
152
+ const metaPart = effect.metadata
153
+ ? ' ' + Object.entries(effect.metadata).map(([k, v]) => `${k}=${String(v)}`).join(' ')
154
+ : ''
155
+ log(`gw-trace dispatch stage=${effect.stage}${keyPart}${metaPart}\n`)
156
+ return
157
+ }
158
+
159
+ // The cases below are KNOWN effect kinds that this PR does NOT
160
+ // cut over. The imperative paths still run for them; the
161
+ // dispatcher logs the event so future cutover PRs can grep for
162
+ // exactly the call sites to migrate.
163
+ case 'deliverToBridge':
164
+ case 'bufferInbound':
165
+ case 'persistInbound':
166
+ case 'setTurnStarted':
167
+ case 'clearTurnStarted':
168
+ case 'noteOutbound':
169
+ case 'firePoke':
170
+ case 'deliverPermVerdict':
171
+ case 'persistPermVerdict': {
172
+ log(`gw-trace dispatch not-yet-cutover effect=${effect.kind}\n`)
173
+ return
174
+ }
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Test hook: invoke the per-effect dispatcher directly so unit tests
180
+ * can drive individual effects against mocks without constructing a
181
+ * full effects array.
182
+ */
183
+ export function __dispatchOneForTests(effect: Effect, ctx: DispatchCtx): void {
184
+ dispatchOne(effect, ctx)
185
+ }
186
+
187
+ /** Type re-export — convenience for test fixtures. */
188
+ export type { MachineInboundMessage }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Unit tests for `inbound-delivery-machine-dispatch.ts`.
3
+ *
4
+ * Per RFC PR 3 cutover: the dispatcher executes effects against real
5
+ * primitives. These tests pin the contract per effect kind — which
6
+ * primitive gets called, with what args, exactly once. The gate that
7
+ * catches a double-drain regression at the imperative call sites.
8
+ *
9
+ * Scope of THIS file matches the PR's scope: bridgeUp's three effects
10
+ * (drainBuffer, redeliverPersistedPermVerdicts, logTrace) are wired
11
+ * and tested end-to-end. Other effect kinds are asserted to log a
12
+ * `not-yet-cutover` trace without throwing — those are the future
13
+ * cutover seam.
14
+ */
15
+
16
+ import { describe, expect, it, vi, beforeEach } from 'vitest'
17
+ import {
18
+ __dispatchOneForTests,
19
+ dispatchEffects,
20
+ isDispatchEnabled,
21
+ } from '../gateway/inbound-delivery-machine-dispatch'
22
+ import type { DispatchCtx } from '../gateway/inbound-delivery-machine-dispatch'
23
+ import { createPendingInboundBuffer } from '../gateway/pending-inbound-buffer'
24
+ import { createPendingPermissionBuffer } from '../gateway/pending-permission-decisions'
25
+ import type { Effect } from '../gateway/inbound-delivery-machine'
26
+
27
+ function makeCtx(overrides?: Partial<DispatchCtx>): {
28
+ ctx: DispatchCtx
29
+ logs: string[]
30
+ sendToAgent: ReturnType<typeof vi.fn>
31
+ clientSend: ReturnType<typeof vi.fn>
32
+ inbound: ReturnType<typeof createPendingInboundBuffer>
33
+ perm: ReturnType<typeof createPendingPermissionBuffer>
34
+ } {
35
+ const logs: string[] = []
36
+ const sendToAgent = vi.fn(() => true)
37
+ const clientSend = vi.fn()
38
+ const inbound = createPendingInboundBuffer()
39
+ const perm = createPendingPermissionBuffer()
40
+ const ctx: DispatchCtx = {
41
+ selfAgent: 'test-agent',
42
+ ipcServer: { sendToAgent } as never,
43
+ pendingInboundBuffer: inbound,
44
+ inboundSpool: null,
45
+ pendingPermissionBuffer: perm,
46
+ client: { send: clientSend, agentName: 'test-agent', id: 'c1' } as never,
47
+ log: (line: string) => logs.push(line),
48
+ ...overrides,
49
+ }
50
+ return { ctx, logs, sendToAgent, clientSend, inbound, perm }
51
+ }
52
+
53
+ describe('isDispatchEnabled', () => {
54
+ it('defaults to true when the kill-switch env-var is unset', () => {
55
+ expect(isDispatchEnabled()).toBe(true)
56
+ })
57
+ })
58
+
59
+ describe('dispatchEffects — drainBuffer', () => {
60
+ it('redelivers buffered inbound to the registered client', () => {
61
+ const { ctx, clientSend, inbound } = makeCtx()
62
+ const msg1 = { type: 'inbound' as const, id: 'm1', chat_id: '1', text: 'hello', meta: { source: 'telegram' } }
63
+ const msg2 = { type: 'inbound' as const, id: 'm2', chat_id: '1', text: 'world', meta: { source: 'cron' } }
64
+ inbound.push('test-agent', msg1 as never)
65
+ inbound.push('test-agent', msg2 as never)
66
+ expect(inbound.depth('test-agent')).toBe(2)
67
+
68
+ dispatchEffects([{ kind: 'drainBuffer' }], ctx)
69
+
70
+ expect(clientSend).toHaveBeenCalledTimes(2)
71
+ expect(clientSend).toHaveBeenNthCalledWith(1, msg1)
72
+ expect(clientSend).toHaveBeenNthCalledWith(2, msg2)
73
+ expect(inbound.depth('test-agent')).toBe(0)
74
+ })
75
+
76
+ it('no-ops on empty buffer (no log, no send)', () => {
77
+ const { ctx, clientSend, logs, inbound } = makeCtx()
78
+ expect(inbound.depth('test-agent')).toBe(0)
79
+
80
+ dispatchEffects([{ kind: 'drainBuffer' }], ctx)
81
+
82
+ expect(clientSend).not.toHaveBeenCalled()
83
+ expect(logs.filter((l) => l.includes('drainBuffer'))).toHaveLength(0)
84
+ })
85
+
86
+ it('falls back to ipcServer.sendToAgent when no client is set', () => {
87
+ const { ctx, sendToAgent, clientSend, inbound } = makeCtx({ client: undefined })
88
+ const msg = { type: 'inbound' as const, id: 'm1', chat_id: '1', text: 'hi', meta: { source: 'cron' } }
89
+ inbound.push('test-agent', msg as never)
90
+
91
+ dispatchEffects([{ kind: 'drainBuffer' }], ctx)
92
+
93
+ expect(clientSend).not.toHaveBeenCalled()
94
+ expect(sendToAgent).toHaveBeenCalledTimes(1)
95
+ expect(sendToAgent).toHaveBeenCalledWith('test-agent', msg)
96
+ })
97
+ })
98
+
99
+ describe('dispatchEffects — redeliverPersistedPermVerdicts', () => {
100
+ it('drains pendingPermissionBuffer to the client', () => {
101
+ const { ctx, clientSend, perm } = makeCtx()
102
+ const v1 = {
103
+ type: 'permission_decision' as const,
104
+ requestId: 'r1',
105
+ behavior: 'allow' as const,
106
+ updatedInput: {},
107
+ timestamp: 0,
108
+ }
109
+ const v2 = {
110
+ type: 'permission_decision' as const,
111
+ requestId: 'r2',
112
+ behavior: 'deny' as const,
113
+ updatedInput: {},
114
+ timestamp: 0,
115
+ }
116
+ perm.push('test-agent', v1 as never)
117
+ perm.push('test-agent', v2 as never)
118
+
119
+ dispatchEffects([{ kind: 'redeliverPersistedPermVerdicts' }], ctx)
120
+
121
+ expect(clientSend).toHaveBeenCalledTimes(2)
122
+ expect(clientSend).toHaveBeenNthCalledWith(1, v1)
123
+ expect(clientSend).toHaveBeenNthCalledWith(2, v2)
124
+ })
125
+
126
+ it('no-ops on empty perm buffer', () => {
127
+ const { ctx, clientSend, logs } = makeCtx()
128
+ dispatchEffects([{ kind: 'redeliverPersistedPermVerdicts' }], ctx)
129
+ expect(clientSend).not.toHaveBeenCalled()
130
+ expect(logs.filter((l) => l.includes('redeliverPerm'))).toHaveLength(0)
131
+ })
132
+ })
133
+
134
+ describe('dispatchEffects — logTrace', () => {
135
+ it('writes a single grep-friendly line', () => {
136
+ const { ctx, logs } = makeCtx()
137
+ dispatchEffects(
138
+ [{ kind: 'logTrace', stage: 'bridge_recover', metadata: { foo: 'bar' } }],
139
+ ctx,
140
+ )
141
+ expect(logs).toHaveLength(1)
142
+ expect(logs[0]).toContain('gw-trace dispatch stage=bridge_recover')
143
+ expect(logs[0]).toContain('foo=bar')
144
+ })
145
+ })
146
+
147
+ describe('dispatchEffects — bridgeUp composite (all three effects in order)', () => {
148
+ it('drains perm verdicts THEN inbound buffer THEN logs', () => {
149
+ const { ctx, clientSend, inbound, perm, logs } = makeCtx()
150
+ const verdict = {
151
+ type: 'permission_decision' as const,
152
+ requestId: 'rA',
153
+ behavior: 'allow' as const,
154
+ updatedInput: {},
155
+ timestamp: 0,
156
+ }
157
+ const inMsg = { type: 'inbound' as const, id: 'mB', chat_id: '1', text: 't', meta: { source: 'cron' } }
158
+ perm.push('test-agent', verdict as never)
159
+ inbound.push('test-agent', inMsg as never)
160
+
161
+ // Effects in the order the machine returns them for bridgeUp.
162
+ dispatchEffects(
163
+ [
164
+ { kind: 'redeliverPersistedPermVerdicts' },
165
+ { kind: 'drainBuffer' },
166
+ { kind: 'logTrace', stage: 'bridge_recover' },
167
+ ],
168
+ ctx,
169
+ )
170
+
171
+ expect(clientSend).toHaveBeenCalledTimes(2)
172
+ expect(clientSend).toHaveBeenNthCalledWith(1, verdict)
173
+ expect(clientSend).toHaveBeenNthCalledWith(2, inMsg)
174
+ expect(logs.some((l) => l.includes('stage=bridge_recover'))).toBe(true)
175
+ })
176
+ })
177
+
178
+ describe('dispatchEffects — kill switch', () => {
179
+ // The kill switch is checked at module-load time so we can't toggle
180
+ // it inside the test process easily. We validate the contract that
181
+ // the helper exists and respects the env-var — see isDispatchEnabled.
182
+ it('exposes the kill-switch check', () => {
183
+ expect(typeof isDispatchEnabled()).toBe('boolean')
184
+ })
185
+ })
186
+
187
+ describe('dispatchOne — not-yet-cutover effects log without throwing', () => {
188
+ const notYet: Effect['kind'][] = [
189
+ 'deliverToBridge',
190
+ 'bufferInbound',
191
+ 'persistInbound',
192
+ 'setTurnStarted',
193
+ 'clearTurnStarted',
194
+ 'noteOutbound',
195
+ 'firePoke',
196
+ 'deliverPermVerdict',
197
+ 'persistPermVerdict',
198
+ ]
199
+
200
+ for (const kind of notYet) {
201
+ it(`'${kind}' logs not-yet-cutover and does NOT touch primitives`, () => {
202
+ const { ctx, sendToAgent, clientSend, logs } = makeCtx()
203
+ // Construct a minimal valid effect for each kind. Most carry a
204
+ // payload that the dispatcher ignores in this PR.
205
+ const effect = synthesizeNotYetCutoverEffect(kind)
206
+ expect(() => __dispatchOneForTests(effect, ctx)).not.toThrow()
207
+ expect(sendToAgent).not.toHaveBeenCalled()
208
+ expect(clientSend).not.toHaveBeenCalled()
209
+ expect(logs.some((l) => l.includes(`not-yet-cutover effect=${kind}`))).toBe(true)
210
+ })
211
+ }
212
+ })
213
+
214
+ function synthesizeNotYetCutoverEffect(kind: Effect['kind']): Effect {
215
+ const k = 'c1:_' as never
216
+ const msg = { msgId: 1, isSteering: false, payload: null } as never
217
+ const verdict = { requestId: 'r', behavior: 'allow' as const, payload: null } as never
218
+ switch (kind) {
219
+ case 'deliverToBridge':
220
+ return { kind, key: k, msg }
221
+ case 'bufferInbound':
222
+ return { kind, key: k, msg }
223
+ case 'persistInbound':
224
+ return { kind, key: k, msg }
225
+ case 'setTurnStarted':
226
+ return { kind, key: k, at: 0 }
227
+ case 'clearTurnStarted':
228
+ return { kind, key: k }
229
+ case 'noteOutbound':
230
+ return { kind, key: k, at: 0 }
231
+ case 'firePoke':
232
+ return { kind, key: k, level: 'fallback' }
233
+ case 'deliverPermVerdict':
234
+ return { kind, verdict }
235
+ case 'persistPermVerdict':
236
+ return { kind, verdict }
237
+ default:
238
+ throw new Error(`unreachable: ${kind}`)
239
+ }
240
+ }