@vellumai/assistant 0.9.0-dev.202606181324.0804a69 → 0.9.0-dev.202606181513.fd39213

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.
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Tests for the inbound admission-floor enforcement in `routeSetup`.
3
+ *
4
+ * `routeSetup` is pure routing logic but reads several module-level singletons
5
+ * (config, trust resolver, pending verification session, invite store). These
6
+ * are mocked so the table below can drive trust class, member status, pending
7
+ * challenge, and active invites independently of any DB.
8
+ *
9
+ * The floor verdict is exercised through the REAL `enforceAdmissionPolicy`
10
+ * floor tables (`TRUST_CLASS_RANK` × `ADMISSION_FLOOR`), with the exempt-channel
11
+ * short-circuit bypassed in the mock so the tests assert the true floor
12
+ * semantics independent of any channel's exempt status (`phone` is now
13
+ * enforced).
14
+ */
15
+
16
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
17
+
18
+ import {
19
+ ADMISSION_FLOOR,
20
+ type AdmissionPolicy,
21
+ } from "@vellumai/gateway-client";
22
+
23
+ import type {
24
+ ChannelPolicy,
25
+ ChannelStatus,
26
+ } from "../../contacts/types.js";
27
+ import type {
28
+ ActorTrustContext,
29
+ TrustClass,
30
+ } from "../../runtime/actor-trust-resolver.js";
31
+ import { TRUST_CLASS_RANK } from "../../runtime/actor-trust-resolver.js";
32
+
33
+ mock.module("../../util/logger.js", () => ({
34
+ getLogger: () =>
35
+ new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
36
+ }));
37
+
38
+ mock.module("../../config/loader.js", () => ({
39
+ getConfig: () => ({ calls: { verification: { enabled: false } } }),
40
+ }));
41
+
42
+ // Controllable resolved trust context.
43
+ let nextTrust: ActorTrustContext;
44
+ mock.module("../../runtime/actor-trust-resolver.js", () => ({
45
+ resolveActorTrust: () => nextTrust,
46
+ // Re-export the real rank table; the floor mock below consumes it.
47
+ TRUST_CLASS_RANK,
48
+ }));
49
+
50
+ // Controllable pending verification challenge.
51
+ let pendingChallenge: unknown = null;
52
+ mock.module("../../runtime/channel-verification-service.js", () => ({
53
+ getPendingSession: () => pendingChallenge,
54
+ }));
55
+
56
+ // Controllable active voice invites.
57
+ let activeInvites: Array<{
58
+ friendName: string | null;
59
+ guardianName: string | null;
60
+ expiresAt?: number;
61
+ }> = [];
62
+ mock.module("../../memory/invite-store.js", () => ({
63
+ findActiveVoiceInvites: () => activeInvites,
64
+ }));
65
+
66
+ // Real floor semantics, exemption bypassed (see file header).
67
+ mock.module(
68
+ "../../runtime/routes/inbound-stages/admission-policy.js",
69
+ () => ({
70
+ enforceAdmissionPolicy: (input: {
71
+ trustClass: TrustClass;
72
+ memberStatus: ChannelStatus | undefined;
73
+ policy: AdmissionPolicy;
74
+ }) => {
75
+ if (input.memberStatus === "blocked" || input.memberStatus === "revoked") {
76
+ return {
77
+ admitted: false,
78
+ reason:
79
+ input.memberStatus === "blocked"
80
+ ? "member_blocked"
81
+ : "member_revoked",
82
+ shouldChallenge: false,
83
+ effectivePolicy: input.policy,
84
+ };
85
+ }
86
+ const rank = TRUST_CLASS_RANK[input.trustClass];
87
+ const floor = ADMISSION_FLOOR[input.policy];
88
+ if (rank >= floor) return { admitted: true };
89
+ return {
90
+ admitted: false,
91
+ reason: `admission_policy_${input.policy}`,
92
+ shouldChallenge: false,
93
+ effectivePolicy: input.policy,
94
+ };
95
+ },
96
+ }),
97
+ );
98
+
99
+ const { routeSetup } = await import("../relay-setup-router.js");
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Fixtures
103
+ // ---------------------------------------------------------------------------
104
+
105
+ function makeTrust(
106
+ trustClass: TrustClass,
107
+ channel?: { status: ChannelStatus; policy?: ChannelPolicy; role?: string },
108
+ ): ActorTrustContext {
109
+ const memberRecord = channel
110
+ ? {
111
+ contact: {
112
+ displayName: "Test Caller",
113
+ role: channel.role ?? "trusted_contact",
114
+ } as never,
115
+ channel: {
116
+ id: "ch_1",
117
+ status: channel.status,
118
+ policy: channel.policy ?? "allow",
119
+ } as never,
120
+ }
121
+ : null;
122
+ return {
123
+ canonicalSenderId: "+15551234567",
124
+ guardianBindingMatch: null,
125
+ memberRecord,
126
+ trustClass,
127
+ actorMetadata: {
128
+ identifier: "+15551234567",
129
+ displayName: undefined,
130
+ senderDisplayName: undefined,
131
+ memberDisplayName: undefined,
132
+ username: undefined,
133
+ channel: "phone",
134
+ trustStatus: trustClass,
135
+ },
136
+ } as ActorTrustContext;
137
+ }
138
+
139
+ function route(admissionPolicy?: AdmissionPolicy | null) {
140
+ return routeSetup({
141
+ callSessionId: "cs_1",
142
+ session: null, // inbound
143
+ from: "+15551234567",
144
+ to: "+15559999999",
145
+ admissionPolicy,
146
+ });
147
+ }
148
+
149
+ beforeEach(() => {
150
+ pendingChallenge = null;
151
+ activeInvites = [];
152
+ });
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Floor table: trustClass × policy → admit/deny
156
+ // ---------------------------------------------------------------------------
157
+
158
+ describe("routeSetup — admission floor table", () => {
159
+ // For "known" classes (trusted_contact, guardian) the resolver yields a
160
+ // member channel; unknown/unverified flow through the unknown ACL branch.
161
+ function setTrust(trustClass: TrustClass) {
162
+ if (trustClass === "guardian") {
163
+ nextTrust = makeTrust("guardian", {
164
+ status: "active",
165
+ role: "guardian",
166
+ });
167
+ } else if (trustClass === "trusted_contact") {
168
+ nextTrust = makeTrust("trusted_contact", { status: "active" });
169
+ } else if (trustClass === "unverified_contact") {
170
+ nextTrust = makeTrust("unverified_contact", { status: "unverified" });
171
+ } else {
172
+ nextTrust = makeTrust("unknown");
173
+ }
174
+ }
175
+
176
+ const cases: Array<{
177
+ policy: AdmissionPolicy;
178
+ admits: TrustClass[];
179
+ denies: TrustClass[];
180
+ }> = [
181
+ {
182
+ policy: "strangers",
183
+ admits: ["unknown", "unverified_contact", "trusted_contact", "guardian"],
184
+ denies: [],
185
+ },
186
+ {
187
+ policy: "any_contact",
188
+ admits: ["unverified_contact", "trusted_contact", "guardian"],
189
+ denies: ["unknown"],
190
+ },
191
+ {
192
+ policy: "trusted_contacts",
193
+ admits: ["trusted_contact", "guardian"],
194
+ denies: ["unknown", "unverified_contact"],
195
+ },
196
+ {
197
+ policy: "guardian_only",
198
+ admits: ["guardian"],
199
+ denies: ["unknown", "unverified_contact", "trusted_contact"],
200
+ },
201
+ {
202
+ policy: "no_one",
203
+ admits: [],
204
+ denies: ["unknown", "unverified_contact", "trusted_contact", "guardian"],
205
+ },
206
+ ];
207
+
208
+ for (const { policy, admits, denies } of cases) {
209
+ for (const trustClass of denies) {
210
+ test(`${policy} denies ${trustClass}`, () => {
211
+ setTrust(trustClass);
212
+ const { outcome } = route(policy);
213
+ expect(outcome.action).toBe("deny");
214
+ if (outcome.action === "deny") {
215
+ expect(outcome.logReason).toBe(
216
+ `Inbound voice admission floor: ${policy}`,
217
+ );
218
+ }
219
+ });
220
+ }
221
+ for (const trustClass of admits) {
222
+ test(`${policy} admits ${trustClass}`, () => {
223
+ setTrust(trustClass);
224
+ const { outcome } = route(policy);
225
+ expect(outcome.action).not.toBe("deny");
226
+ });
227
+ }
228
+ }
229
+ });
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Blocked / revoked always deny
233
+ // ---------------------------------------------------------------------------
234
+
235
+ describe("routeSetup — blocked / revoked", () => {
236
+ test("blocked caller is denied (pre-floor ACL check)", () => {
237
+ nextTrust = makeTrust("unknown", { status: "blocked" });
238
+ const { outcome } = route("strangers");
239
+ expect(outcome.action).toBe("deny");
240
+ });
241
+
242
+ test("revoked member is denied under permissive floor", () => {
243
+ // Revoked → resolver classifies as unknown; the floor mock denies on
244
+ // memberStatus regardless of the permissive policy.
245
+ nextTrust = makeTrust("unknown", { status: "revoked" });
246
+ const { outcome } = route("strangers");
247
+ expect(outcome.action).toBe("deny");
248
+ });
249
+ });
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // admissionPolicy null/undefined → current behavior unchanged
253
+ // ---------------------------------------------------------------------------
254
+
255
+ describe("routeSetup — no policy preserves current behavior", () => {
256
+ test("unknown caller → name_capture", () => {
257
+ nextTrust = makeTrust("unknown");
258
+ expect(route(null).outcome.action).toBe("name_capture");
259
+ expect(route(undefined).outcome.action).toBe("name_capture");
260
+ });
261
+
262
+ test("unverified known caller → unverified_caller", () => {
263
+ nextTrust = makeTrust("unverified_contact", { status: "unverified" });
264
+ expect(route(null).outcome.action).toBe("unverified_caller");
265
+ });
266
+
267
+ test("trusted contact → normal_call", () => {
268
+ nextTrust = makeTrust("trusted_contact", { status: "active" });
269
+ expect(route(null).outcome.action).toBe("normal_call");
270
+ });
271
+
272
+ test("guardian → normal_call", () => {
273
+ nextTrust = makeTrust("guardian", { status: "active", role: "guardian" });
274
+ expect(route(null).outcome.action).toBe("normal_call");
275
+ });
276
+
277
+ test("member policy deny still denies", () => {
278
+ nextTrust = makeTrust("trusted_contact", {
279
+ status: "active",
280
+ policy: "deny",
281
+ });
282
+ expect(route(null).outcome.action).toBe("deny");
283
+ });
284
+ });
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Active permissive floor admits straight to a normal call
288
+ // ---------------------------------------------------------------------------
289
+ // When a policy is ACTIVELY set and admits the caller, the floor is the access
290
+ // decision: unknown/unverified callers bypass name_capture / unverified_caller
291
+ // and connect directly. With a null policy the legacy identity flows persist.
292
+
293
+ describe("routeSetup — permissive floor admits to normal_call", () => {
294
+ test("any_contact admits an unverified_contact to normal_call (not unverified_caller)", () => {
295
+ nextTrust = makeTrust("unverified_contact", { status: "unverified" });
296
+ const { outcome } = route("any_contact");
297
+ expect(outcome.action).toBe("normal_call");
298
+ });
299
+
300
+ test("strangers admits an unknown caller to normal_call (not name_capture)", () => {
301
+ nextTrust = makeTrust("unknown");
302
+ const { outcome } = route("strangers");
303
+ expect(outcome.action).toBe("normal_call");
304
+ });
305
+
306
+ test("strangers admits an unverified_contact to normal_call", () => {
307
+ nextTrust = makeTrust("unverified_contact", { status: "unverified" });
308
+ const { outcome } = route("strangers");
309
+ expect(outcome.action).toBe("normal_call");
310
+ });
311
+
312
+ test("null policy preserves legacy name_capture for unknown caller", () => {
313
+ nextTrust = makeTrust("unknown");
314
+ expect(route(null).outcome.action).toBe("name_capture");
315
+ });
316
+
317
+ test("null policy preserves legacy unverified_caller for unverified caller", () => {
318
+ nextTrust = makeTrust("unverified_contact", { status: "unverified" });
319
+ expect(route(null).outcome.action).toBe("unverified_caller");
320
+ });
321
+
322
+ test("trusted_contacts (default) still denies unknown and unverified", () => {
323
+ nextTrust = makeTrust("unknown");
324
+ expect(route("trusted_contacts").outcome.action).toBe("deny");
325
+ nextTrust = makeTrust("unverified_contact", { status: "unverified" });
326
+ expect(route("trusted_contacts").outcome.action).toBe("deny");
327
+ });
328
+ });
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // Invites & pending challenges bypass the floor
332
+ // ---------------------------------------------------------------------------
333
+
334
+ describe("routeSetup — floor bypasses", () => {
335
+ test("active voice invite bypasses the floor (no_one policy)", () => {
336
+ nextTrust = makeTrust("unknown");
337
+ activeInvites = [{ friendName: "Friend", guardianName: "Guardian" }];
338
+ const { outcome } = route("no_one");
339
+ expect(outcome.action).toBe("invite_redemption");
340
+ });
341
+
342
+ test("pending verification challenge bypasses the floor (no_one policy)", () => {
343
+ // A pending challenge for a known member routes to verification regardless
344
+ // of the floor.
345
+ nextTrust = makeTrust("trusted_contact", { status: "active" });
346
+ pendingChallenge = { id: "vs_1" };
347
+ const { outcome } = route("no_one");
348
+ expect(outcome.action).toBe("verification");
349
+ });
350
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Gateway-backed channel admission policy reader.
3
+ *
4
+ * Reads a channel's resolved admission floor from the gateway via the
5
+ * `get_channel_admission_policy` IPC route. Admission must never break call
6
+ * setup, so this reader FAILS OPEN: any transport failure, malformed shape,
7
+ * or thrown error resolves to `null` (= admit, no enforcement). A policy is
8
+ * only returned when the gateway responds with `{ policy: <valid policy> }`.
9
+ *
10
+ * Caches per channelType with a short TTL, mirroring the conversation
11
+ * threshold cache in `../permissions/gateway-threshold-reader.ts`. Only the
12
+ * result of a SUCCESSFUL gateway round-trip is cached (a valid policy, or an
13
+ * explicit `{ policy: null }` "no enforcement" answer); a transport failure /
14
+ * throw / malformed shape fails open to `null` WITHOUT caching, so a recovered
15
+ * gateway is re-consulted on the next call setup rather than skipping the
16
+ * admission floor for the rest of the TTL.
17
+ */
18
+
19
+ import {
20
+ type AdmissionPolicy,
21
+ isAdmissionPolicy,
22
+ } from "@vellumai/gateway-client";
23
+
24
+ import type { ChannelId } from "../channels/types.js";
25
+ import { ipcCall } from "../ipc/gateway-client.js";
26
+
27
+ const CACHE_TTL_MS = 5_000;
28
+
29
+ // Short IPC timeout so admission fails open PROMPTLY: a gateway that accepts
30
+ // the socket but stalls must never delay a live call handshake. 1s is generous
31
+ // under normal conditions yet far below ipcCall's 5s default.
32
+ const ADMISSION_IPC_TIMEOUT_MS = 1_000;
33
+
34
+ const cache = new Map<
35
+ ChannelId,
36
+ { policy: AdmissionPolicy | null; timestamp: number }
37
+ >();
38
+
39
+ /** Test-only: clear the policy cache. */
40
+ export function _clearCacheForTesting(): void {
41
+ cache.clear();
42
+ }
43
+
44
+ /**
45
+ * Outcome of a single gateway fetch. Only a SUCCESSFUL round-trip (a valid
46
+ * policy, or an explicit `{ policy: null }` "no enforcement" answer) is
47
+ * cacheable. A transport failure, thrown error, or malformed shape is NOT
48
+ * cached so a recovered gateway is re-consulted on the next call setup.
49
+ */
50
+ type FetchResult =
51
+ | { ok: true; policy: AdmissionPolicy | null }
52
+ | { ok: false };
53
+
54
+ async function fetchAdmissionPolicy(
55
+ channelType: ChannelId,
56
+ ): Promise<FetchResult> {
57
+ try {
58
+ // ipcCall() returns undefined on transport failure (socket not found,
59
+ // timeout, parse error). That, a throw, or a malformed shape is a FAILURE:
60
+ // fail open without caching so the next setup re-attempts the IPC.
61
+ const result = (await ipcCall(
62
+ "get_channel_admission_policy",
63
+ { channelType },
64
+ ADMISSION_IPC_TIMEOUT_MS,
65
+ )) as { policy?: unknown } | null | undefined;
66
+
67
+ if (result === undefined) return { ok: false };
68
+ if (result && isAdmissionPolicy(result.policy)) {
69
+ return { ok: true, policy: result.policy };
70
+ }
71
+ // Explicit "no enforcement" — the gateway successfully answered. Cacheable.
72
+ if (result && result.policy === null) return { ok: true, policy: null };
73
+ // Anything else (missing/invalid policy field) is a malformed shape.
74
+ return { ok: false };
75
+ } catch {
76
+ return { ok: false };
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Resolve a channel's admission policy from the gateway, or `null` on any
82
+ * failure / absence. `null` means admit with no enforcement.
83
+ *
84
+ * Always fails open to `null` on failure, but only caches the result of a
85
+ * SUCCESSFUL gateway round-trip — a transient hiccup must not skip the
86
+ * admission floor for the rest of the TTL.
87
+ */
88
+ export async function getChannelAdmissionPolicy(
89
+ channelType: ChannelId,
90
+ ): Promise<AdmissionPolicy | null> {
91
+ const cached = cache.get(channelType);
92
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
93
+ return cached.policy;
94
+ }
95
+
96
+ const result = await fetchAdmissionPolicy(channelType);
97
+ if (!result.ok) {
98
+ // Fail open WITHOUT caching so a recovered gateway is re-consulted next time.
99
+ return null;
100
+ }
101
+
102
+ cache.set(channelType, { policy: result.policy, timestamp: Date.now() });
103
+ return result.policy;
104
+ }
@@ -50,6 +50,7 @@ import {
50
50
  recordCallEvent,
51
51
  updateCallSession,
52
52
  } from "./call-store.js";
53
+ import { getChannelAdmissionPolicy } from "./channel-admission-reader.js";
53
54
  import { finalizeCall } from "./finalize-call.js";
54
55
  import { MediaStreamOutput } from "./media-stream-output.js";
55
56
  import { parseMediaStreamFrame } from "./media-stream-parser.js";
@@ -90,6 +91,18 @@ export class MediaStreamCallSession {
90
91
  private streamSid: string | null = null;
91
92
  private callSid: string | null = null;
92
93
  private disposed = false;
94
+ // True from the synchronous start of handleStart until setup routing
95
+ // completes. Resolving the admission floor yields the event loop, so
96
+ // transcripts/barge-in arriving in this window are ignored — a denied or
97
+ // not-yet-authorized caller's speech is never persisted before the floor
98
+ // and trust ACL are applied. The controller-existence checks below also
99
+ // gate persistence, but this guard makes the intent explicit and covers
100
+ // the brief async window before the controller is created.
101
+ private setupRouting = false;
102
+ // Resolves when the async setup routing kicked off by the `start` frame
103
+ // settles. Exposed via whenSetupSettled() for deterministic test awaiting,
104
+ // since handleStart now yields on the admission-policy read.
105
+ private setupSettled: Promise<void> = Promise.resolve();
93
106
 
94
107
  // ── Operational diagnostics counters ──────────────────────────────
95
108
  /** Number of barge-in attempts that were accepted (assistant was speaking). */
@@ -141,6 +154,14 @@ export class MediaStreamCallSession {
141
154
  return this.controller;
142
155
  }
143
156
 
157
+ /**
158
+ * Resolves once the async setup routing started by the `start` frame has
159
+ * settled. Test-only convenience — production code does not await this.
160
+ */
161
+ whenSetupSettled(): Promise<void> {
162
+ return this.setupSettled;
163
+ }
164
+
144
165
  /**
145
166
  * Feed a raw WebSocket message into the session.
146
167
  *
@@ -154,7 +175,16 @@ export class MediaStreamCallSession {
154
175
  // Intercept `start` to bootstrap the session before forwarding.
155
176
  const parseResult = parseMediaStreamFrame(raw);
156
177
  if (parseResult.ok && parseResult.event.event === "start") {
157
- this.handleStart(parseResult.event);
178
+ // handleStart resolves the admission floor asynchronously; the
179
+ // setupRouting guard set synchronously at its top ensures inbound
180
+ // frames forwarded below are ignored until routing completes.
181
+ this.setupSettled = this.handleStart(parseResult.event).catch((err) => {
182
+ log.error(
183
+ { err, callSessionId: this.callSessionId },
184
+ "Media-stream setup routing failed",
185
+ );
186
+ this.setupRouting = false;
187
+ });
158
188
  }
159
189
 
160
190
  // Always forward to the STT session (it handles all event types).
@@ -296,7 +326,12 @@ export class MediaStreamCallSession {
296
326
 
297
327
  // ── Internal: media-stream event handlers ─────────────────────────
298
328
 
299
- private handleStart(event: MediaStreamStartEvent): void {
329
+ private async handleStart(event: MediaStreamStartEvent): Promise<void> {
330
+ // Enter the setup-pending window synchronously, before any await. The
331
+ // admission-policy read below yields the event loop, so frames forwarded
332
+ // to the STT session can produce transcripts/barge-in before routing
333
+ // completes — those are dropped while setupRouting is true.
334
+ this.setupRouting = true;
300
335
  this.streamSid = event.streamSid;
301
336
  this.callSid = event.start.callSid;
302
337
 
@@ -337,12 +372,32 @@ export class MediaStreamCallSession {
337
372
  const from = session?.fromNumber ?? "";
338
373
  const to = session?.toNumber ?? "";
339
374
 
375
+ // Resolve the phone channel's inbound admission floor so the trust floor
376
+ // (e.g. guardian_only) is enforced on this transport too — not just the
377
+ // gateway webhook's no_one kill switch. The reader fails open to `null`
378
+ // by contract, so a transport hiccup admits the caller.
379
+ const admissionPolicy = await getChannelAdmissionPolicy("phone");
380
+
381
+ // The admission-policy read above yields the event loop; if Twilio closed
382
+ // the WebSocket meanwhile, the close handler will have called destroy().
383
+ // Abort setup so we don't create a controller or speak on a disposed
384
+ // session.
385
+ if (this.disposed) {
386
+ log.info(
387
+ { callSessionId: this.callSessionId },
388
+ "Media-stream session disposed during admission read — aborting setup",
389
+ );
390
+ this.setupRouting = false;
391
+ return;
392
+ }
393
+
340
394
  const { outcome, resolved } = routeSetup({
341
395
  callSessionId: this.callSessionId,
342
396
  session: session ?? null,
343
397
  from,
344
398
  to,
345
399
  customParameters: event.start.customParameters,
400
+ admissionPolicy,
346
401
  });
347
402
 
348
403
  log.info(
@@ -377,6 +432,10 @@ export class MediaStreamCallSession {
377
432
  );
378
433
  registerCallController(this.callSessionId, this.controller);
379
434
 
435
+ // Routing/ACL cleared — leave the setup-pending window so subsequent
436
+ // transcripts and barge-in are handled normally.
437
+ this.setupRouting = false;
438
+
380
439
  // Fire the initial greeting.
381
440
  this.controller.startInitialGreeting().catch((err) => {
382
441
  log.error(
@@ -525,6 +584,18 @@ export class MediaStreamCallSession {
525
584
 
526
585
  private handleTranscriptFinal(text: string, _durationMs: number): void {
527
586
  if (!text.trim()) return;
587
+
588
+ // Drop transcripts arriving while setup routing is still pending so a
589
+ // not-yet-authorized / floor-denied caller's speech is never persisted
590
+ // before the admission floor and trust ACL are applied.
591
+ if (this.setupRouting) {
592
+ log.debug(
593
+ { callSessionId: this.callSessionId },
594
+ "Transcript received during setup routing — dropping",
595
+ );
596
+ return;
597
+ }
598
+
528
599
  this.transcriptFinalsProduced++;
529
600
 
530
601
  if (!this.controller) {
@@ -560,6 +631,17 @@ export class MediaStreamCallSession {
560
631
  }
561
632
 
562
633
  private handleDtmf(digit: string): void {
634
+ // Drop DTMF arriving while setup routing is still pending so a
635
+ // not-yet-authorized / floor-denied caller's digit is never persisted
636
+ // before the admission floor and trust ACL are applied.
637
+ if (this.setupRouting) {
638
+ log.debug(
639
+ { callSessionId: this.callSessionId, digit },
640
+ "DTMF received during setup routing — dropping",
641
+ );
642
+ return;
643
+ }
644
+
563
645
  log.info(
564
646
  { callSessionId: this.callSessionId, digit },
565
647
  "DTMF digit received on media-stream",