Skip to content

scv-transcript-gate-lambda

Status: Current | Last reviewed: 2026-08-06

Ensures exactly one producer writes transcripts to Salesforce for a call that TrueServe Live Translation is handling. It gates Amazon Connect Contact Lens real-time transcript segments, dropping them while translation is active and forwarding everything else untouched.

Source

apps/scv-transcript-gate-lambda

The problem

Live Translation publishes each utterance to Salesforce over SCRT2 — the original and its translation, per direction. Contact Lens transcribes the same audio in parallel and its own consumer writes to SCRT2 too, so a translated call gets:

  • duplicates — every utterance twice in the Conversation component, and
  • garbage — Contact Lens transcribes a bilingual call under one LanguageCode, so the half it guessed wrong comes back phonetic ("Hola, me llamo Erik" → "Ola Leamo Erik").

Neither is fixable inside our connector: the second producer is Salesforce's own CloudFormation-provisioned consumer, reading the Contact Lens Kinesis stream.

Topology

The gate takes over the subscription and re-invokes the untouched consumer:

Connect (REAL_TIME_CONTACT_ANALYSIS_SEGMENTS)
  └─ Kinesis  dcc-dev-main-sls-trueServe-transcription
       ├─ [NEW ESM]  scv-transcript-gate ──┐  drop while translating
       │                                    └─ lambda.invoke(Event) ─┐
       └─ [CFN ESM, DISABLED]  ────────────────────────────────────  ▼
                                        amazonMultiorg-MultiorgContactLensConsumer
                                          └─ …ContactLensProcessorFunction → SCRT2

A wrapper rather than a patch: the consumer belongs to Salesforce's AFVoiceTS-MultiorgProvisioningServiceStack, so forking it means owning a fork forever. Its contract is just event.Records, and the gate passes the batch through with only that list narrowed.

The decision

Default-allow, keyed on a per-contact attribute:

TrueServeTranslationActive Action
"true" drop — the pipeline owns this call's transcript
anything else forward
unreadable / API failure forward

Failing open is deliberate. A broken gate means duplicate transcripts, which is visible and recoverable; failing closed means a call with no transcript at all, which is not.

Scope is deliberately narrow — the gate only ever suppresses segments for a contact that is being translated right now. Every other call, and every non-translated stretch of a translated call, behaves exactly as it did before the gate existed.

Three non-obvious properties:

  • This cannot be an event-source-mapping FilterCriteria. Those filter record content; "is this call being translated right now" is per-contact state living outside the record.
  • connect:GetContactAttributes authorizes on the CONTACT, not the instance — the IAM resource is ${connect_instance_arn}/contact/*.
  • It works on an already-disconnected contact, which is load-bearing: Contact Lens segments lag the audio, so the last ones arrive after the call ended.

The attribute is written by the SCV connector at translation start (before the first gRPC stream opens, so well ahead of the first seconds-lagged segment) and cleared at stop — see connect-scv-translator.

Runtime

Aspect Value
Language Python 3.12
Handler handler.handler
Trigger Kinesis (Contact Lens real-time segments)
Dependencies stdlib + boto3 (runtime-provided)

Environment

Variable Purpose
DOWNSTREAM_FUNCTION_ARN Consumer to re-invoke for allowed records (required)
CONNECT_INSTANCE_ID Connect instance the attribute is read from (required)
TRANSLATION_ACTIVE_ATTRIBUTE Attribute name. Default TrueServeTranslationActive
ATTRIBUTE_CACHE_TTL_SECONDS Per-contact decision cache. Default 30
MAX_DOWNSTREAM_BATCH Records per downstream invoke. Default 5
DRY_RUN true logs decisions and forwards everything

MAX_DOWNSTREAM_BATCH is not a tuning knob: the multiorg consumer bails out and processes nothing when handed more than 5 records, so the gate chunks to that. It mirrors the CloudFormation mapping's own BatchSize.

Deploying

Two halves, both required:

  1. A new event-source mapping from the Contact Lens stream to this function, mirroring the CloudFormation one (batch size 5, 1 s window, parallelization factor 4, retry attempts 0) — but with StartingPosition: LATEST, not TRIM_HORIZON. A new mapping at TRIM_HORIZON replays the stream's full retention of already-delivered segments into Salesforce, duplicating transcripts on calls that finished days ago.
  2. The CloudFormation-managed mapping set to Enabled: false.

Do not repoint the existing mapping — CloudFormation owns it and reverts the change on the next stack update. Disabling is idempotent and trivially reversible: re-enable it and delete ours to fall back to today's behaviour. Re-check its state after any Salesforce stack update.

Both mappings may be enabled at once — each gets its own copy of every record, which is what DRY_RUN validation wants and exactly what must not be left in place afterwards.

Terraform: infra/modules/aws/scv-transcript-gate via infra/stacks/scv-transcript-gate-vanguard, which ships dry_run = true.

Vanguard

cd apps/scv-transcript-gate-lambda
python3 -m pytest test_handler.py -q

Lambda tests are not wired into CI — run them locally.

The Vanguard gate was deployed with the aws CLI, not Terraform (2026-08-06; Terraform isn't installed in the devcontainer), mirroring the module exactly and tagged trueserve:managed-by=aws-cli. There is no state file, so a first terraform apply would try to create the same names again and fail — the stack's header carries the seven terraform import commands to adopt them. Live ids:

Resource Value
Function / role tsf-vanguard-scv-transcript-gate (us-east-1)
Our mapping 9245256c-3943-4505-9434-d88897b55093 — LATEST, batch 5, Enabled
CloudFormation mapping 82d12dad-5e1f-4553-b5df-bf3c05018700 — still Enabled (step 1)
DRY_RUN true

Operating it by CLI has one trap: update-function-configuration --environment replaces the whole variable map, so a partial update drops DOWNSTREAM_FUNCTION_ARN and the gate goes inert — it logs DOWNSTREAM_FUNCTION_ARN unset — N record(s) not forwarded at ERROR and forwards nothing. Always re-send every variable.

Both decision paths were proven by synthetic invokes before any live call, with DOWNSTREAM_FUNCTION_ARN temporarily blanked so nothing could reach Salesforce's consumer. Note what each proof rests on:

  • Forward / fail-open — a contact without the attribute logged no Could not read … — forwarding warning. That absence is the real assertion: is_translation_active swallows an IAM denial and forwards, so a missing grant and a missing attribute produce the same return value and only the warning separates them. It proves connect:GetContactAttributes is authorized on ${instance_arn}/contact/*.
  • Drop — the attribute was set on an already-disconnected contact (itself the invariant Contact Lens's lag depends on), and the invoke logged Dropping Contact Lens segment for translated contact … followed by DRY_RUN — forwarding 1 record(s) the gate would drop, returning dropped: 0. One invoke proves both the strict-true match and that DRY_RUN overrides it.

Production

Terraform in the customer's AWS account. One Connect instance can serve several Salesforce orgs; the gate is per-instance and keys entirely on the contact, so it needs no org awareness. Its IAM is deliberately separate from the SCV connector's auth — different integrations, different credentials.

See also

  • connect-scv-translator — the connector that sets the attribute and the second S3 sink (published to the instance's own bucket under Translations/Voice/, deliberately not Connect's Analysis/Voice/, so it can't be read as Contact Lens output)
  • Realtime translation