From eec2da7270f270593c85d608f68a5761bc350edd Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Wed, 23 Sep 2026 22:50:57 +0200 Subject: [PATCH 1/2] perf(chat-bot): resolve a workspace's credentials only when a transport calls out The relay read the workspace row (a Postgres connection and a credential decrypt) before the gates ran, to build the map ConnectorCredentials carried. Every Slack thread message paid for it, including the un-mentioned ones the ownership gate then drops. ConnectorCredentials is now an Effect a transport runs per call, sharing one memoized read with resolveWorkspace, so a turned-away message opens no connection and one that proceeds still opens exactly one. --- apps/chat-bot/src/relay/run.ts | 80 +++----------- apps/chat-bot/src/relay/turn.test.ts | 102 +++++++++++++++++- apps/chat-bot/src/relay/turn.ts | 45 ++++++++ packages/chat-platform/README.md | 3 +- .../src/connectors/discord/outbound.test.ts | 2 +- .../src/connectors/discord/outbound.ts | 41 +++---- .../src/connectors/slack/outbound.test.ts | 2 +- .../src/connectors/slack/outbound.ts | 78 +++++++------- packages/chat-platform/src/outbound.ts | 11 +- 9 files changed, 238 insertions(+), 126 deletions(-) diff --git a/apps/chat-bot/src/relay/run.ts b/apps/chat-bot/src/relay/run.ts index aa0e994ea8..f236d38293 100644 --- a/apps/chat-bot/src/relay/run.ts +++ b/apps/chat-bot/src/relay/run.ts @@ -7,13 +7,7 @@ * the database, and none of it belongs on the path Cloudflare evaluates when it validates the * uploaded script. */ -import { - ConnectorCredentials, - WORKSPACE_CREDENTIALS, - type ChatConnector, - type ConnectorConfig, - type InboundEvent, -} from "@maple/chat-platform" +import { ConnectorCredentials, type ChatConnector, type InboundEvent } from "@maple/chat-platform" import { connectors } from "@maple/chat-platform/connectors" import * as MapleCloudflareSDK from "@maple-dev/effect-sdk/cloudflare" import { chatSessionStub } from "@maple/domain/chat-session-stub" @@ -36,7 +30,7 @@ import { resolveChatWorkspace, } from "@maple/backend/services/integrations/chat-workspace-rows" import { resolveConnectorConfig } from "../config.ts" -import { relayInboundEvent, WorkspaceLookupFailed, type RelayPorts, type RelayWorkspace } from "./turn.ts" +import { relayWithWorkspace, WorkspaceLookupFailed, type RelayPorts, type ResolvedWorkspace } from "./turn.ts" /** * This Worker's own SDK instance, at module scope so its buffers are the isolate's. @@ -186,46 +180,14 @@ const lookupWorkspace = ( ).pipe(lookupFailed(connectorId, "The chat workspace could not be read")), ) -/** What the one lookup answers: the relay's half, and the transport's. */ -interface ResolvedWorkspace { - readonly relay: RelayWorkspace - readonly credentials: string | undefined -} - -/** - * The config the connector's transport is built from: what the deployment set, plus this - * workspace's own credential where it stored one. - */ -const connectorCredentials = ( - config: ConnectorConfig, - resolved: Option.Option, -): ConnectorConfig => { - if (Option.isNone(resolved) || resolved.value.credentials === undefined) return config - return new Map(config).set(WORKSPACE_CREDENTIALS, resolved.value.credentials) -} - const ports = ( host: RelayHost, connector: ChatConnector, - lookup: ( - connectorId: ChatConnectorId, - workspaceId: string, - externalUserId: string | undefined, - ) => Effect.Effect, WorkspaceLookupFailed>, + resolveWorkspace: RelayPorts["resolveWorkspace"], ): RelayPorts => ({ outbound: connector.outbound, supportsIdentity: connector.identity !== undefined, - /** - * The workspace, and — when a caller names a chat account — the Maple user it is linked to, in - * ONE connection. - * - * Sequential rather than parallel because the second question needs the first one's answer: a - * link is per org, and the org is what the workspace names. - */ - resolveWorkspace: (connectorId, workspaceId, externalUserId) => - Effect.map(lookup(connectorId, workspaceId, externalUserId), (found) => - Option.map(found, (workspace) => workspace.relay), - ), + resolveWorkspace, forgetWorkspace: (connectorId, workspaceId) => withDatabase( host.env, @@ -273,28 +235,20 @@ export const runInboundEvent = async (host: RelayHost, event: InboundEvent): Pro const config = resolveConnectorConfig(host.env, connector) if (config._tag === "missing") return - // One read for the whole event, memoized: the relay asks which org this is, and the transport is - // built from the credential the same row carries. `Effect.cached` is what keeps that one - // Postgres connection rather than two, without hoisting the read ahead of the relay's own - // decision about whether this message is a turn at all. await Effect.runPromise( - Effect.gen(function* () { - const lookup = yield* Effect.cached( - lookupWorkspace( - host, - connector, - event.connector, - event.workspaceId, - // Only a click names a person; a message is answered for the workspace. - event.type === "action" ? event.actor.id : undefined, - ), - ) - const resolved = yield* Effect.orElseSucceed(lookup, () => Option.none()) - yield* relayInboundEvent( - event, - ports(host, connector, () => lookup), - ).pipe(Effect.provideService(ConnectorCredentials, connectorCredentials(config.config, resolved))) - }).pipe( + relayWithWorkspace( + event, + config.config, + lookupWorkspace( + host, + connector, + event.connector, + event.workspaceId, + // Only a click names a person; a message is answered for the workspace. + event.type === "action" ? event.actor.id : undefined, + ), + (resolveWorkspace) => ports(host, connector, resolveWorkspace), + ).pipe( // oxlint-disable-next-line effecttsgo/strict-effect-provide Effect.provide(Layer.mergeAll(FetchHttpClient.layer, workerEnvLayer(host.env), telemetry.layer)), // On the fiber rather than in a `finally`: the flush is what exports this event's spans, diff --git a/apps/chat-bot/src/relay/turn.test.ts b/apps/chat-bot/src/relay/turn.test.ts index 6ca0f8e284..644d306b67 100644 --- a/apps/chat-bot/src/relay/turn.test.ts +++ b/apps/chat-bot/src/relay/turn.test.ts @@ -25,6 +25,7 @@ import { chatActionControlId, chatConnectorId, ChatOutboundError, + ConnectorCredentials, encodeChatActionToken, type ChatBlock, type ChatChartRef, @@ -33,13 +34,15 @@ import { type ChatOutbound, type ChatTarget, type InboundAction, + type InboundEvent, type InboundMessage, type InboundWorkspaceRemoved, + WORKSPACE_CREDENTIALS, } from "@maple/chat-platform" import { Context, Duration, Effect, Logger, Option, References, Schema, Tracer } from "effect" import { TestClock } from "effect/testing" import { ConversationNotRecorded, FOLLOW_UP_WINDOW_MS } from "./conversation.ts" -import { relayInboundEvent, WorkspaceLookupFailed, type RelayPorts } from "./turn.ts" +import { relayInboundEvent, relayWithWorkspace, WorkspaceLookupFailed, type RelayPorts } from "./turn.ts" const TESTCHAT = chatConnectorId("testchat") const ORG = Schema.decodeSync(OrgId)("org_1") @@ -1289,3 +1292,100 @@ describe("relaying everything else a connector reports", () => { }), ) }) + +describe("reading the workspace row", () => { + const NOW = Date.parse("2026-09-23T12:00:00.000Z") + const followUp: InboundMessage = { + ...mention, + messageId: "message-2", + channelId: CONVERSATION, + mentionsBot: false, + } + const answered: ReadonlyArray = [ + { + id: "a1", + role: "assistant", + text: "Checkout is slow.", + toolCalls: [], + createdAt: NOW - 60_000, + startSeq: 2, + }, + ] + + /** + * The event relayed the way `run.ts` relays it: one counted read (the database connection and + * the decrypt), behind a connector that posts with the credential that read carries. + */ + const relay = (event: InboundEvent, stub: ChatSessionStub, opened?: ReadonlyArray) => + Effect.gen(function* () { + const platform = chat() + const reads = { count: 0 } + const postedWith: Array = [] + const outbound: ChatOutbound = { + ...platform.outbound, + transport: Effect.gen(function* () { + const credentials = yield* ConnectorCredentials + const inner = yield* platform.outbound.transport + const using = (effect: Effect.Effect) => + Effect.flatMap(credentials, (config) => { + postedWith.push(config.get(WORKSPACE_CREDENTIALS)) + return effect + }) + return { + ...inner, + post: (target, blocks) => using(inner.post(target, blocks)), + edit: (ref, blocks) => using(inner.edit(ref, blocks)), + } + }), + } + yield* relayWithWorkspace( + event, + new Map(), + Effect.sync(() => { + reads.count += 1 + return Option.some({ relay: { orgId: ORG }, credentials: "sealed" }) + }), + (resolveWorkspace) => ({ + ...host(platform.outbound, stub, { opened }).ports, + outbound, + resolveWorkspace, + }), + ) + return { reads: reads.count, postedWith } + }) + + it.effect("reads nothing for a message it will not answer", () => + Effect.gen(function* () { + const result = yield* relay(followUp, session([silentTurn], { transcript: answered }).stub) + expect(result).toEqual({ reads: 0, postedWith: [] }) + }), + ) + + it.effect("reads it once for a follow-up in a conversation the bot owns", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW) + const agent = session([silentTurn], { transcript: answered }) + const result = yield* relay(followUp, agent.stub, [CONVERSATION]) + expect(agent.turns).toHaveLength(1) + expect(result.reads).toBe(1) + }), + ) + + it.effect("reads it once for a mention, and posts with the credential it carried", () => + Effect.gen(function* () { + const result = yield* relay(mention, session([silentTurn]).stub) + expect(result.reads).toBe(1) + expect(result.postedWith.length).toBeGreaterThan(0) + expect(new Set(result.postedWith)).toEqual(new Set(["sealed"])) + }), + ) + + it.effect("reads it once for a click, and edits with the credential it carried", () => + Effect.gen(function* () { + const agent = session([], { transcript: decidedTranscript("Approved by Ada.\nDone.") }) + const result = yield* relay(click(), agent.stub) + expect(agent.settlements).toHaveLength(1) + expect(result).toEqual({ reads: 1, postedWith: ["sealed"] }) + }), + ) +}) diff --git a/apps/chat-bot/src/relay/turn.ts b/apps/chat-bot/src/relay/turn.ts index 7ebbf7705d..5580c2159e 100644 --- a/apps/chat-bot/src/relay/turn.ts +++ b/apps/chat-bot/src/relay/turn.ts @@ -12,8 +12,10 @@ * connector. */ import { + ConnectorCredentials, decodeChatActionControlId, driveChatTurn, + WORKSPACE_CREDENTIALS, type ChatActionRequest, type ChatChartRef, type ChatConversation, @@ -21,6 +23,7 @@ import { type ChatOutbound, type ChatOutboundTransport, type ChatTarget, + type ConnectorConfig, type InboundAction, type InboundEvent, type InboundMessage, @@ -567,3 +570,45 @@ export const relayInboundEvent = ( ), ), ) + +/** What the one workspace read answers: the relay's half, and the transport's credential. */ +export interface ResolvedWorkspace { + readonly relay: RelayWorkspace + readonly credentials: string | undefined +} + +/** + * {@link relayInboundEvent}, with the workspace row read at most once — and only when asked for. + * + * The relay's `resolveWorkspace` and the transport's `ConnectorCredentials` share one memoized + * read, so a message the gates turn away opens no connection and decrypts nothing, and one that + * proceeds costs exactly one of each. + */ +export const relayWithWorkspace = ( + event: InboundEvent, + config: ConnectorConfig, + lookup: Effect.Effect, WorkspaceLookupFailed>, + ports: (resolveWorkspace: RelayPorts["resolveWorkspace"]) => RelayPorts, +): Effect.Effect> => + Effect.flatMap(Effect.cached(lookup), (read) => + relayInboundEvent( + event, + ports(() => + Effect.map( + read, + Option.map((found) => found.relay), + ), + ), + ).pipe( + Effect.provideService( + ConnectorCredentials, + // A read that failed posts with the deployment's config alone; the relay has already + // refused to answer on it. + Effect.map(Effect.orElseSucceed(read, Option.none), (found) => + Option.isNone(found) || found.value.credentials === undefined + ? config + : new Map(config).set(WORKSPACE_CREDENTIALS, found.value.credentials), + ), + ), + ), + ) diff --git a/packages/chat-platform/README.md b/packages/chat-platform/README.md index 861fbed23a..7381448259 100644 --- a/packages/chat-platform/README.md +++ b/packages/chat-platform/README.md @@ -24,7 +24,8 @@ ChatEvent stream ──▶ makeChatTranscript ──▶ renderChatMessage ── its token from `workspaceId` inside its own transport — the contract carries the address, never the secret. The credential itself arrives as `ConnectorCredentials`, the configuration the host resolved from the names the connector declared, so a host can drive any connector's outbound half - with two services it knows: that one and an HTTP client. + with two services it knows: that one and an HTTP client. It is an Effect the transport runs per + call, not at acquisition, so an event the host never answers never pays for resolving it. - `src/driver.ts` — one turn, named by the assistant message id `beginTurn` answered with: placeholder, throttled edits, retraction, splitting, approvals, and a short notice for a turn that did not simply finish. A stream from seq 0 replays whole earlier turns, which is why the turn is named diff --git a/packages/chat-platform/src/connectors/discord/outbound.test.ts b/packages/chat-platform/src/connectors/discord/outbound.test.ts index 0b2e8609e5..b5b5d3bbcf 100644 --- a/packages/chat-platform/src/connectors/discord/outbound.test.ts +++ b/packages/chat-platform/src/connectors/discord/outbound.test.ts @@ -34,7 +34,7 @@ const stub = (attempts: ReadonlyArray) => { seen, layer: Layer.mergeAll( Layer.succeed(HttpClient.HttpClient)(client), - Layer.succeed(ConnectorCredentials)(new Map([[BOT_TOKEN_CONFIG, "bot-token"]])), + Layer.succeed(ConnectorCredentials)(Effect.succeed(new Map([[BOT_TOKEN_CONFIG, "bot-token"]]))), ), } } diff --git a/packages/chat-platform/src/connectors/discord/outbound.ts b/packages/chat-platform/src/connectors/discord/outbound.ts index 80592c2031..adb41eef97 100644 --- a/packages/chat-platform/src/connectors/discord/outbound.ts +++ b/packages/chat-platform/src/connectors/discord/outbound.ts @@ -182,7 +182,8 @@ export const discordOutbound: ChatOutbound, operation: ChatOutboundOperation, route: string, request: HttpClientRequest.HttpClientRequest, @@ -206,7 +208,7 @@ export const discordOutbound: ChatOutbound => { - const tryOnce = ( - count: number, - ): Effect.Effect => - attempt(operation, route, request).pipe( - Effect.catchTag("@maple/chat-platform/connectors/discord/RateLimited", (limited) => - count >= MAX_RATE_LIMIT_ATTEMPTS - ? Effect.fail( - failed(operation, "Discord kept rate limiting this message", { - status: 429, - }), - ) - : Effect.sleep(limited.wait).pipe(Effect.andThen(tryOnce(count + 1))), - ), - ) - return tryOnce(1) - } + ): Effect.Effect => + Effect.flatMap(token, (bearer) => { + const tryOnce = ( + count: number, + ): Effect.Effect => + attempt(bearer, operation, route, request).pipe( + Effect.catchTag("@maple/chat-platform/connectors/discord/RateLimited", (limited) => + count >= MAX_RATE_LIMIT_ATTEMPTS + ? Effect.fail( + failed(operation, "Discord kept rate limiting this message", { + status: 429, + }), + ) + : Effect.sleep(limited.wait).pipe(Effect.andThen(tryOnce(count + 1))), + ), + ) + return tryOnce(1) + }) const body = (blocks: ReadonlyArray) => HttpClientRequest.bodyJsonUnsafe(renderDiscordMessage(blocks)) diff --git a/packages/chat-platform/src/connectors/slack/outbound.test.ts b/packages/chat-platform/src/connectors/slack/outbound.test.ts index d840c334c0..e1374e5cb1 100644 --- a/packages/chat-platform/src/connectors/slack/outbound.test.ts +++ b/packages/chat-platform/src/connectors/slack/outbound.test.ts @@ -40,7 +40,7 @@ const stub = (attempts: ReadonlyArray, credentials: string | null = CRE seen, layer: Layer.mergeAll( Layer.succeed(HttpClient.HttpClient)(client), - Layer.succeed(ConnectorCredentials)(config), + Layer.succeed(ConnectorCredentials)(Effect.succeed(config)), ), } } diff --git a/packages/chat-platform/src/connectors/slack/outbound.ts b/packages/chat-platform/src/connectors/slack/outbound.ts index 306b15ec3b..4f57ea7abb 100644 --- a/packages/chat-platform/src/connectors/slack/outbound.ts +++ b/packages/chat-platform/src/connectors/slack/outbound.ts @@ -164,9 +164,12 @@ export const slackOutbound: ChatOutbound Redacted.make(value.bot_token)) + // Per call, not here: acquiring the transport must not cost the host its credential read. + const token = Effect.map(yield* ConnectorCredentials, (config: ConnectorConfig) => + Option.map(decodeSlackCredentials(config.get(WORKSPACE_CREDENTIALS)), (value) => + Redacted.make(value.bot_token), + ), + ) /** * One Web API call, as one client span. @@ -241,40 +244,41 @@ export const slackOutbound: ChatOutbound | undefined }, ChatOutboundError - > => { - // Checked BEFORE the client span is opened. The workspace having no stored token is a - // Maple-side state — nobody linked it, or the envelope could not be opened — and - // recording it inside a `Slack.request` span would put a failed edge on the service map - // for a call that was never made. - if (Option.isNone(token)) { - return Effect.fail(failed(operation, "This Slack workspace is not connected to Maple")) - } - const bearer = token.value - const tryOnce = ( - count: number, - ): Effect.Effect< - { - readonly ts?: string | undefined - readonly messages?: ReadonlyArray | undefined - }, - ChatOutboundError - > => - attempt(bearer, operation, method, url, payload).pipe( - Effect.catchTag("@maple/chat-platform/connectors/slack/RateLimited", (limited) => - // A read of the conversation is CONTEXT, and the turn has not started yet — - // waiting a rate limit out here delays the answer to buy background the model - // can do without. An answer is worth waiting for; the history behind it is not. - count >= MAX_RATE_LIMIT_ATTEMPTS || operation === "history" - ? Effect.fail( - failed(operation, "Slack kept rate limiting this message", { - status: 429, - }), - ) - : Effect.sleep(limited.wait).pipe(Effect.andThen(tryOnce(count + 1))), - ), - ) - return tryOnce(1) - } + > => + Effect.flatMap(token, (token) => { + // Checked BEFORE the client span is opened. The workspace having no stored token is a + // Maple-side state — nobody linked it, or the envelope could not be opened — and + // recording it inside a `Slack.request` span would put a failed edge on the service + // map for a call that was never made. + if (Option.isNone(token)) { + return Effect.fail(failed(operation, "This Slack workspace is not connected to Maple")) + } + const bearer = token.value + const tryOnce = ( + count: number, + ): Effect.Effect< + { + readonly ts?: string | undefined + readonly messages?: ReadonlyArray | undefined + }, + ChatOutboundError + > => + attempt(bearer, operation, method, url, payload).pipe( + Effect.catchTag("@maple/chat-platform/connectors/slack/RateLimited", (limited) => + // A read of the conversation is CONTEXT, and the turn has not started yet — + // waiting a rate limit out here delays the answer to buy background the model + // can do without. An answer is worth waiting for; the history behind it is not. + count >= MAX_RATE_LIMIT_ATTEMPTS || operation === "history" + ? Effect.fail( + failed(operation, "Slack kept rate limiting this message", { + status: 429, + }), + ) + : Effect.sleep(limited.wait).pipe(Effect.andThen(tryOnce(count + 1))), + ), + ) + return tryOnce(1) + }) /** `thread_ts` is left out entirely when there is no thread; Slack rejects an empty one. */ const body = ( diff --git a/packages/chat-platform/src/outbound.ts b/packages/chat-platform/src/outbound.ts index f96551bbbf..0992565002 100644 --- a/packages/chat-platform/src/outbound.ts +++ b/packages/chat-platform/src/outbound.ts @@ -21,10 +21,15 @@ import type { ChatBlock } from "./render/blocks" * called — so it resolves the names the connector declared (`requiredConfig`) and supplies the map * under this one service. Two host-provided services then cover every connector's outbound half: * this and an HTTP client. + * + * An Effect rather than the map, run when a transport is about to call its platform: answering it + * can cost the host a database read and a decrypt, and most inbound events are never answered. The + * host memoizes it per event, so running it on every call is free. */ -export class ConnectorCredentials extends Context.Service()( - "@maple/chat-platform/ConnectorCredentials", -) {} +export class ConnectorCredentials extends Context.Service< + ConnectorCredentials, + Effect.Effect +>()("@maple/chat-platform/ConnectorCredentials") {} /** * The entry under which the host puts the conversation's OWN stored credential into From 6a21bc7696e109b3e5e63d38901ea96c15a8732a Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Wed, 23 Sep 2026 22:53:19 +0200 Subject: [PATCH 2/2] test(chat-bot): cover the unlinked workspace's credential fallback --- apps/chat-bot/src/relay/run.ts | 2 +- apps/chat-bot/src/relay/turn.test.ts | 21 +++++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/chat-bot/src/relay/run.ts b/apps/chat-bot/src/relay/run.ts index f236d38293..35b0a3c6e6 100644 --- a/apps/chat-bot/src/relay/run.ts +++ b/apps/chat-bot/src/relay/run.ts @@ -7,7 +7,7 @@ * the database, and none of it belongs on the path Cloudflare evaluates when it validates the * uploaded script. */ -import { ConnectorCredentials, type ChatConnector, type InboundEvent } from "@maple/chat-platform" +import type { ChatConnector, ConnectorCredentials, InboundEvent } from "@maple/chat-platform" import { connectors } from "@maple/chat-platform/connectors" import * as MapleCloudflareSDK from "@maple-dev/effect-sdk/cloudflare" import { chatSessionStub } from "@maple/domain/chat-session-stub" diff --git a/apps/chat-bot/src/relay/turn.test.ts b/apps/chat-bot/src/relay/turn.test.ts index 644d306b67..1edf39889c 100644 --- a/apps/chat-bot/src/relay/turn.test.ts +++ b/apps/chat-bot/src/relay/turn.test.ts @@ -1316,7 +1316,11 @@ describe("reading the workspace row", () => { * The event relayed the way `run.ts` relays it: one counted read (the database connection and * the decrypt), behind a connector that posts with the credential that read carries. */ - const relay = (event: InboundEvent, stub: ChatSessionStub, opened?: ReadonlyArray) => + const relay = ( + event: InboundEvent, + stub: ChatSessionStub, + options?: { readonly opened?: ReadonlyArray; readonly linked?: boolean }, + ) => Effect.gen(function* () { const platform = chat() const reads = { count: 0 } @@ -1343,10 +1347,12 @@ describe("reading the workspace row", () => { new Map(), Effect.sync(() => { reads.count += 1 - return Option.some({ relay: { orgId: ORG }, credentials: "sealed" }) + return options?.linked === false + ? Option.none() + : Option.some({ relay: { orgId: ORG }, credentials: "sealed" }) }), (resolveWorkspace) => ({ - ...host(platform.outbound, stub, { opened }).ports, + ...host(platform.outbound, stub, { opened: options?.opened ?? [] }).ports, outbound, resolveWorkspace, }), @@ -1365,7 +1371,7 @@ describe("reading the workspace row", () => { Effect.gen(function* () { yield* TestClock.setTime(NOW) const agent = session([silentTurn], { transcript: answered }) - const result = yield* relay(followUp, agent.stub, [CONVERSATION]) + const result = yield* relay(followUp, agent.stub, { opened: [CONVERSATION] }) expect(agent.turns).toHaveLength(1) expect(result.reads).toBe(1) }), @@ -1380,6 +1386,13 @@ describe("reading the workspace row", () => { }), ) + it.effect("posts with the deployment's config alone for a workspace nobody linked", () => + Effect.gen(function* () { + const result = yield* relay(mention, session([silentTurn]).stub, { linked: false }) + expect(result).toEqual({ reads: 1, postedWith: [undefined] }) + }), + ) + it.effect("reads it once for a click, and edits with the credential it carried", () => Effect.gen(function* () { const agent = session([], { transcript: decidedTranscript("Approved by Ada.\nDone.") })