toga-ai 1.0.481 → 1.0.483

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.
@@ -6,8 +6,8 @@ project: AI-BDR
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-21
10
- owners: [akhokhani, tcox]
9
+ updated: 2026-07-30
10
+ owners: [akhokhani, tcox, bala]
11
11
  files:
12
12
  - ai-bdr/vapi/templates/assistant.template.json
13
13
  - ai-bdr/vapi/templates/system-prompt.template.md
@@ -122,7 +122,7 @@ Returned in `message.analysis.structuredData` on every `end-of-call-report`:
122
122
  | `contactEmail` | string | Email the prospect confirmed; empty if not collected |
123
123
  | `emailMatchedKnown` | boolean | True if confirmed email matches `knownEmail` from variableValues |
124
124
  | `meetingTime` | ISO 8601 | Present if `callOutcome=meeting_booked` |
125
- | `callbackTime` | ISO 8601 | Present if `callOutcome=callback_scheduled`; backend converts a spoken time to ISO. **BUG (2026-07-21): the model frequently emits natural language with spelled-out numbers (e.g. `"in ten minutes"`) which the PHP parser cannot handle — the callback is then silently dropped. See the convertDateTime gotcha in [worker2 vapi-webhook-handler.md](../../worker2/features/vapi-webhook-handler.md). Fix candidate on this side: constrain this field's schema to ISO 8601 or a numeric-relative format so the model never spells the number out.** |
125
+ | `callbackTime` | ISO 8601 *(documented; unconstrained free text in practice)* | Present if `callOutcome=callback_scheduled`. **BUG — the single highest-value schema fix (rescoped 2026-07-30).** The PHP side stores this raw and parses it with PHP's relative-date grammar, which returns null for **22 of 32** realistic phrasings — see below. |
126
126
  | `callbackContactPreference` | string | `phone` / `email` / `sms` |
127
127
  | `callType` | string | Mirrors the `callType` variable injected on this call |
128
128
  | `nextCallOpeningContext` | string | One-sentence coaching note for the next attempt's opener |
@@ -135,6 +135,25 @@ To change the schema: `python scripts/update_structured_output.py` (dry
135
135
  run by default; pass `--apply` to write). Adding a field needs a paired
136
136
  PHP worker handler change — coordinate before deploying.
137
137
 
138
+ ### `callbackTime` — constraining it here is the recommended durable fix
139
+
140
+ The worker's `convertDateTime` is broken by the **preposition / filler word**, not (as previously
141
+ recorded) by spelled-out numbers: `"10 minutes"` parses but `"in 10 minutes"` does not;
142
+ `"tomorrow 3pm"` parses but `"tomorrow at 3pm"` does not. `in`, `at`, `around` and `sometime` all
143
+ break it, and `"later"` — which appears verbatim in the worker's own sample payload — is null.
144
+ Every failure means a **promised callback is silently never placed**.
145
+
146
+ **ISO 8601 parses cleanly on the PHP side today** (`2026-08-01T15:00:00Z` and the `.000Z` variant).
147
+ So: constrain this field's schema to **strict ISO 8601 with offset** and prompt the assistant to
148
+ resolve relative speech to an absolute timestamp before emitting. That removes the entire class of
149
+ failure without touching PHP — which is why it ranks above the PHP-side hardening.
150
+
151
+ **Unverified (2026-07-30):** the live assistant config could not be inspected from the dev box
152
+ (`C:\WWW\info` is the Next.js funnel — no `vapi/` templates, no `scripts/`, zero matches for
153
+ `callbackTime`). Confirm whether the live schema **already** constrains this field, and which
154
+ server-message types each assistant currently subscribes to, before changing anything. Full
155
+ analysis: [worker2 callback-scheduling.md](../../worker2/features/callback-scheduling.md).
156
+
138
157
  ## Environment variables (`.env.example`)
139
158
 
140
159
  ```
@@ -181,6 +200,14 @@ ensure UA spoofing is preserved or you'll see 403s with no body.
181
200
  `callSummary` over a schema fork.
182
201
 
183
202
  ## Change history
203
+ - 2026-07-30 — **Rescoped the `callbackTime` bug and promoted the schema-side fix.** The parse
204
+ failure is caused by the preposition/filler word (`in`, `at`, `around`, `sometime`), **not**
205
+ spelled-out numbers — 22/32 realistic phrasings return null, `"tomorrow at 3pm"` among them.
206
+ Since ISO 8601 parses cleanly in PHP, constraining this field to strict ISO 8601 with offset
207
+ (plus prompting the assistant to resolve relative speech before emitting) is the **recommended
208
+ durable fix**. Flagged that the live assistant config could not be inspected, so it is unverified
209
+ whether the schema already constrains the field or which server-message types each assistant
210
+ subscribes to (this matters — the worker has no `message.type` guard). (tcox)
184
211
  - 2026-07-21 — Live go-live findings. Recorded that the healthcare assistant runs
185
212
  `firstMessageMode = "assistant-waits-for-user"` (Option B) so a silent answerer gets silence
186
213
  (confirmed: 8s mutual-silence call, `endedReason=customer-ended-call`) — testers must speak first.
@@ -6,6 +6,7 @@
6
6
  | [Deploy-Time Auto-Registration to the Shared ALB Target Group (non-production)](features/alb-target-group-auto-registration.md) | TOGA does **not** pay for EB-managed load-balancer registration, so an EB instance is normally **not** added to its environment's ALB target group — a fresh or | worker2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, worker2/ebs/register_instance_to_shared_application_load_balancer.php, worker2/.platform/hooks/prebuild/_shared/040-write-instance-id.sh, worker2/.platform/hooks/prebuild/_shared/041-write-region.sh, worker2/.platform/hooks/postdeploy/015_install_composer.sh, api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, api2/ebs/register_instance_to_shared_application_load_balancer.php |
7
7
  | [All-Client Email Queue Monitor (Monitor/Operations/EmailQueue)](features/all-client-email-queue-monitor.md) | `_Worker_Monitor_Operations::EmailQueue()` is the cross-client health check for the 2.0 outbound email queue (`Logs_<Client>.Email` — see [2.0 Email Send Pipeli | worker2/Worker/Monitor/Operations.php, _underscore/Database.php, _underscore/Query.php, dbchanges2/Logs_Client/2026-05-21 - Email.sql |
8
8
  | [Automated PR Merger — Concurrent Force-Push Clobber Race](features/automated-pr-merger-force-push-race.md) | The automated PR merger `_Worker_Team_GitHub::Merge` (`worker2` `Worker/Team/Github.php`) merges approved PRs to `_production` by **force-pushing from a clone t | Worker/Team/Github.php |
9
+ | [Callback Scheduling ("call me back") — worker2 AI-BDR](features/callback-scheduling.md) | The **AI-BDR callback path**: what happens between a prospect saying *"call me back later"* on a Vapi call and the dialer actually placing that second call. | worker2/Worker/Vapi.php, worker2/Worker/Ai/Bdr/Vapi.php, worker2/Controller/Index.php |
9
10
  | [ClickUp Connectivity Watchdog](features/clickup-connectivity-watchdog.md) | A cron watchdog that emails when the ClickUp integration looks disconnected during business hours. | worker2/Worker/Clickup/Health.php, worker2/Database/ClickupHealthWatchdog.sql |
10
11
  | [ClickUp Design Sprint Automation (Final Design Outcome)](features/clickup-design-sprint-automation.md) | `_Worker_Clickup_Design` is meant to drive the design-sprint workflow in ClickUp via the API, replacing a set of native ClickUp automations. | worker2/Worker/Clickup/Design.php, worker2/Worker/Clickup.php, worker2/Controller/ClickupDesignTest.php, _underscore/Component/Api/Clickup/Clickup.php |
11
12
  | [ClickUp GitHub-tab Auto-linking & Ticket-id Branch Naming](features/clickup-github-autolink.md) | How ClickUp surfaces branches/PRs/commits in a ticket's **GitHub tab**, and the branch / PR-title naming convention that triggers it. | |
@@ -0,0 +1,278 @@
1
+ ---
2
+ title: Callback Scheduling ("call me back") — worker2 AI-BDR
3
+ framework: "2.0"
4
+ repo: worker2
5
+ project: Worker
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-07-30
10
+ owners: [tcox, bala]
11
+ files:
12
+ - worker2/Worker/Vapi.php
13
+ - worker2/Worker/Ai/Bdr/Vapi.php
14
+ - worker2/Controller/Index.php
15
+ related:
16
+ - vapi-webhook-handler.md
17
+ - ../../ai-bdr/features/vapi-integration.md
18
+ - ../../ai-bdr/features/call-orchestration.md
19
+ - ../../ai-bdr/workflows/safe-call-loop-testing.md
20
+ ---
21
+
22
+ ## Summary
23
+
24
+ The **AI-BDR callback path**: what happens between a prospect saying *"call me back later"* on a
25
+ Vapi call and the dialer actually placing that second call. It spans the webhook
26
+ (`Worker/Vapi.php` — capture, persist, parse, schedule) and the dialer
27
+ (`Worker/Ai/Bdr/Vapi.php` — eligibility, redial).
28
+
29
+ **It is currently broken in production.** The requested callback is captured and stored
30
+ correctly, but `convertDateTime` cannot parse most of the phrasings prospects actually use, so
31
+ `actionScheduleCallback` aborts one line before the write and **no callback is ever scheduled**.
32
+ This doc records the source-verified root cause, three further defects found on the same path,
33
+ the recommended fix order, and the production queries that confirm it with real data.
34
+
35
+ > **Status / handoff (2026-07-30).** Diagnosis only — **nothing has been fixed and no worker2
36
+ > code was modified.** tcox investigated (read-only, `_production` @ `ef50345`); **bala is
37
+ > picking the topic up next.** Immediate next step: run **Q1** below against `Client_True` to
38
+ > confirm the root cause with production data, then decide whether the durable fix lands in the
39
+ > **Vapi assistant structured-output schema** (recommended) or in the PHP parser.
40
+
41
+ ## Key files / entry points
42
+
43
+ | Location | Role |
44
+ |---|---|
45
+ | `Worker/Vapi.php:99, :109–122, :144` | Extracts `callbackTime` from the end-of-call report |
46
+ | `Worker/Vapi.php:161–173, :189–194, :213–215, :256–266` | Persists `c_callbackTime` **raw** + outcome/action onto the attempt |
47
+ | `Worker/Vapi.php:387–431` (`actionScheduleCallback`) | Parses the time and writes `Contacts.dtNextContactRequested` |
48
+ | `Worker/Vapi.php:609–635` (`convertDateTime`) | The parser — **the failure point** |
49
+ | `Worker/Ai/Bdr/Vapi.php:234–241, :314–331` | Dialer eligibility — honours (or blocks) the callback |
50
+
51
+ ## How it works (end to end)
52
+
53
+ 1. **Capture.** The requested time arrives at
54
+ `message.artifact.structuredOutputs.<schema-uuid>.result.callbackTime`
55
+ (`Worker/Vapi.php:99` → `:109–122` → `:144`) — **not** in `analysis`, which is always empty.
56
+ The published schema documents ISO 8601
57
+ ([vapi-integration.md](../../ai-bdr/features/vapi-integration.md)), but in practice the field
58
+ is **unconstrained free text**; the file's own sample payload contains
59
+ `"callbackTime": "later"` (`Worker/Vapi.php:45`).
60
+ 2. **Persist (raw).** `updateContactAttempt` stores the string with **no conversion**
61
+ (`:213–215`) alongside `c_callOutcome='CALLBACK_SCHEDULED'` / `c_actionToTake='SCHEDULE_CALLBACK'`
62
+ (map at `:161–173`, applied `:189–194`), via `PUT /contact-attempts/{uuid}` (`:256–266`).
63
+ `c_callbackTime` is `VARCHAR(255)` (dbchanges2 `_modules/Ai/Bdr/2026-03-04 bdrCustomfields.sql:84`),
64
+ so a malformed value **survives persistence intact** and only dies at the act step.
65
+ 3. **Act.** `executePostCallActions` dispatches `SCHEDULE_CALLBACK` → `actionScheduleCallback`,
66
+ which resolves the campaign timezone (`:400`), calls `self::convertDateTime($callbackTime, …)`
67
+ (`:401`), and on success writes `Contacts.dtNextContactRequested` (`:414`) and
68
+ `contactCallTypeId = CALLBACK` (`:417`) via `PUT /contacts/{uuid}` (`:420–430`).
69
+ 4. **Redial.** The dialer treats a due `dtNextContactRequested` as the privileged path:
70
+ it **bypasses call windows, weekly cadence, call-window exception dates and prior DNC**
71
+ (`Ai/Bdr/Vapi.php:238–303`, `:314–331`). Callbacks also sort first in the per-run queue (`:334`).
72
+
73
+ ## Root cause — `convertDateTime` cannot parse the times prospects actually say
74
+
75
+ `actionScheduleCallback` aborts when the parser returns null:
76
+
77
+ - `Worker/Vapi.php:399–406` — logs `[AI-BDR Webhook] Cannot parse callbackTime "…"` and **`return`s
78
+ early**. `dtNextContactRequested` is never written, `contactCallTypeId` is never flipped, and
79
+ nothing downstream ever schedules the call.
80
+ - `Worker/Vapi.php:609–635` — the parser, which leans entirely on PHP's relative-date grammar.
81
+
82
+ **Empirically measured** (parser extracted and executed on PHP 8.1.10): **22 of 32 realistic
83
+ phrasings return null.** Confirmed null: `later`, `in 10 minutes`, `in ten minutes`,
84
+ `tomorrow at 3pm`, `tomorrow at 3`, `Monday at 9am`, `in an hour`, `in 2 days`, `this afternoon`,
85
+ `tomorrow morning`, `end of day`, `around 2pm`, `15 minutes from now`, `next week sometime`.
86
+
87
+ ### The correction — it is the PREPOSITION, not spelled-out numbers
88
+
89
+ An earlier note on [vapi-webhook-handler.md](vapi-webhook-handler.md) attributed this to
90
+ spelled-out numbers (`"in ten minutes"`). **That is understated and misleading.** The trigger is
91
+ the **preposition / filler word**:
92
+
93
+ | Parses | Returns null |
94
+ |---|---|
95
+ | `10 minutes` | `in 10 minutes` |
96
+ | `tomorrow 3pm` | `tomorrow at 3pm` |
97
+ | `2026-08-01T15:00:00Z` (and the `.000Z` variant) | `around 2pm`, `next week sometime` |
98
+
99
+ The words **`in`, `at`, `around`, `sometime`** break PHP's relative-date grammar. Note that
100
+ `"tomorrow at 3pm"` — the exact phrasing the code comment at `:399` claims works — **fails**.
101
+ Spelled-out numbers are a real but secondary contributor. **ISO 8601 parses cleanly**, which is
102
+ why the durable fix belongs in the Vapi schema.
103
+
104
+ ### Ruled out — do not re-investigate the downstream wiring
105
+
106
+ The "recorded as data but never scheduled as an action" hypothesis is **FALSE**; it fails one
107
+ line *before* the write.
108
+
109
+ - `actionScheduleCallback` **does** write `dtNextContactRequested` (`:414`) and
110
+ `contactCallTypeId` (`:417`) via `PUT /contacts/{uuid}` (`:420–430`). An exhaustive grep confirms
111
+ this is the **only** writer of `dtNextContactRequested` outside the dialer's own IMMEDIATE /
112
+ 403-retry stamps (`Ai/Bdr/Vapi.php:421`, `:477`).
113
+ - The dialer **honours** it, bypassing call windows, cadence, exception dates and prior DNC.
114
+ - **`CALLBACK_SCHEDULED` is NOT a terminal outcome.** The exclusion at
115
+ `Ai/Bdr/Vapi.php:317–330` lists only `c_dncRequested=1`, `c_actionToTake='DO_NOT_CALL'`, and
116
+ `c_callOutcome IN ('MEETING_BOOKED','DNC_REQUESTED')` — and the whole block is short-circuited
117
+ when `dtNextContactRequested IS NOT NULL` (`:316`).
118
+
119
+ ## Defect 2 (COMPLIANCE) — times that *do* parse are frequently wrong, sometimes in the past
120
+
121
+ There is **no future/sanity check** between the parse and the write (`Worker/Vapi.php:401–414`),
122
+ and dialer eligibility is simply `dtNextContactRequested <= NOW()` (`Ai/Bdr/Vapi.php:241`).
123
+
124
+ Measured at **2026-07-30 09:29 CT**, campaign timezone `America/New_York`:
125
+
126
+ | Spoken | Resolved to | Effect |
127
+ |---|---|---|
128
+ | `Thursday` | 2026-07-29 23:00 | **In the past** — redials on the very next cron minute |
129
+ | `tomorrow` | 2026-07-30 23:00 | An **11 PM call** (midnight ET rendered as 23:00 CT) |
130
+ | `3:00 PM tomorrow` | 2026-07-30 23:00 | Time silently dropped — **wrong day AND wrong time** |
131
+ | `2pm` | earlier today | In the past whenever the call ended after 2pm |
132
+
133
+ Because the callback path **deliberately bypasses `CampaignCallWindows`**, nothing downstream
134
+ catches an 11 PM dial or a redial 60 seconds later. **Treat this as TCPA/compliance exposure, not
135
+ just a UX bug.**
136
+
137
+ ## Defect 3 — `maxAttemptsPerContact` is NOT bypassed for callbacks
138
+
139
+ The attempt-cap predicate sits **outside** the `dtNextContactRequested` OR-branch
140
+ (`Ai/Bdr/Vapi.php:234–235`; the branch begins at `:238`). Default cap is **3**
141
+ (dbchanges2 `Client/2026-06-03- BLANK_CLIENT_DATABASE.sql:1852`).
142
+
143
+ So a prospect who asks for a callback **on their 3rd attempt** gets `dtNextContactRequested` set
144
+ correctly and is then **permanently ineligible**. Callbacks bypass call windows, cadence,
145
+ exception dates and prior DNC — but **not** the attempt cap. This is a silent policy conflict:
146
+ the cap overrides an explicit customer request. Decide the policy explicitly (exempt
147
+ callback-requested contacts, or grant +1).
148
+
149
+ ## Defect 4 — the failure is silent all the way out to Vapi
150
+
151
+ `actionScheduleCallback`'s write (`Worker/Vapi.php:420–430`) is the **only** write in the file
152
+ with **no try/catch** (compare `:255–284` and `:363–367`), and it is called with
153
+ `$throwExceptionOnApiError = true` — `_underscore` `Component/Api/Toga/Toga.php:167` / `:172`
154
+ throw. The controller then **swallows the throw and returns HTTP 200**
155
+ (`worker2/Controller/Index.php:511–526`), so **Vapi never retries** and the callback is lost with
156
+ no external signal. The only trace is `Core.WorkerJobs.failureReason`.
157
+
158
+ ## Smaller defects found on the same path
159
+
160
+ - **An empty `Campaigns.timezone` silently kills the callback.** `Worker/Vapi.php:400` uses `??`,
161
+ which does **not** catch `''`. `Campaigns.timezone` is `varchar(64) NOT NULL` with **no default**
162
+ (blank DB `:1851`), so `''` is storable, `new DateTimeZone('')` throws, and the parse returns
163
+ null. Verified that `timezone='Central'` also fails — **only IANA identifiers work.**
164
+ - **No audit of what was actually scheduled.** Only the raw string is stored; the resolved
165
+ datetime is never persisted, so a failed parse cannot be deterministically re-run.
166
+ - **The attempt's call type gets clobbered.** `Worker/Vapi.php:178` writes the **contact's**
167
+ `contactCallTypeId` onto the **attempt**, overwriting what the dialer stamped at
168
+ `Ai/Bdr/Vapi.php:701` (and nulls it when the contact's is NULL).
169
+ - **`Contacts.contactCallTypeId` is never reset to SCHEDULED** after a callback completes, so
170
+ every subsequent call keeps taking the callback prompt branch (`Ai/Bdr/Vapi.php:371–372`).
171
+ - **`Campaigns.dateEnd` kills late callbacks.** `validateCampaignReady:784–786` throws once today
172
+ > `dateEnd`; it is caught at `ProcessCampaignCalls:144–146` and returned as `success:false`. A
173
+ callback promised for after the campaign end date **silently never fires**.
174
+ - **Per-run cap of 10** (`MAX_CALLS_PER_MINUTE` at `:8`, applied `:339–340`). Callbacks sort first
175
+ (`:334`) so this rarely bites, but >10 simultaneously-due callbacks queue.
176
+
177
+ ## Recommended fix direction (decided 2026-07-30 — priority order)
178
+
179
+ 1. **DURABLE FIX — Vapi assistant config side.** Constrain `callbackTime` in the structured-output
180
+ schema to **strict ISO 8601 with offset**, and prompt the assistant to resolve relative speech
181
+ to an absolute timestamp before emitting. ISO parses cleanly today, so this removes the whole
182
+ class of failure. See [vapi-integration.md](../../ai-bdr/features/vapi-integration.md).
183
+ 2. **`Worker/Vapi.php:71–79` and `:135`** — add
184
+ `if (($message->type ?? '') !== 'end-of-call-report') return 'ok';` and stop force-stamping
185
+ `dtEnded` from `NOW()` (set it only from a real `endedAt`). Independent of the callback bug and
186
+ arguably **more urgent** — see the concurrency gotcha in
187
+ [vapi-webhook-handler.md](vapi-webhook-handler.md).
188
+ 3. **`:609–635` and `:403–406`** — defensive normalization (strip leading `in `/`at `/`around `/
189
+ `sometime `, map spelled-out numbers) and, on failure, **schedule a flagged sane default rather
190
+ than `return`** — never silently drop a promised callback.
191
+ 4. **`:401–414`** — clamp the parsed time: if `<= now`, push to the next valid slot; snap into the
192
+ campaign's call windows; reject beyond ~90 days.
193
+ 5. **`Ai/Bdr/Vapi.php:234–235`** — decide the attempt-cap policy explicitly.
194
+ 6. **`:420–430`** — add a try/catch matching the file's own pattern and make failures **loud**
195
+ (Sentry, not just `error_log`).
196
+ 7. **`:400`** — use `?:` instead of `??`, validate against `DateTimeZone::listIdentifiers()`, and
197
+ add a DB default for `Campaigns.timezone`.
198
+ 8. **dbchanges2 `_modules/Ai/Bdr/`** — add a resolved `c_callbackTimeResolved DATETIME` column for
199
+ auditability and re-runnability.
200
+
201
+ ## Diagnostic queries (run against `Client_True`)
202
+
203
+ **Q1 — decisive.** Join attempts to contacts for callbacks in the last 30 days:
204
+
205
+ ```sql
206
+ SELECT ca.attemptId, ca.contactId, ca.campaignId, ca.dtStarted, ca.dtEnded,
207
+ ca.c_callOutcome, ca.c_actionToTake, ca.c_callbackTime,
208
+ c.dtNextContactRequested, c.contactCallTypeId, c.isOkayToCall
209
+ FROM ContactAttempts ca
210
+ JOIN Contacts c ON c.contactId = ca.contactId
211
+ WHERE ca.c_callOutcome = 'CALLBACK_SCHEDULED'
212
+ AND ca.dtEnded >= DATE_SUB(NOW(), INTERVAL 30 DAY)
213
+ ORDER BY ca.dtEnded DESC;
214
+ ```
215
+
216
+ Interpretation:
217
+ - `c_callbackTime` non-empty **AND** `dtNextContactRequested IS NULL` → **the parse-failure root
218
+ cause** (or the silent throw of Defect 4).
219
+ - `c_callbackTime` empty/NULL → the model never emitted the field.
220
+ - `dtNextContactRequested < ca.dtEnded` → **scheduled in the past** (Defect 2).
221
+ - Set correctly → scheduling worked; move to Q3 (attempt cap).
222
+
223
+ **Q2 — what the parser must actually handle:**
224
+
225
+ ```sql
226
+ SELECT c_callbackTime, COUNT(*) AS n
227
+ FROM ContactAttempts
228
+ WHERE c_callbackTime IS NOT NULL AND c_callbackTime <> ''
229
+ GROUP BY c_callbackTime
230
+ ORDER BY n DESC;
231
+ ```
232
+
233
+ **Q3** — contacts with a due `dtNextContactRequested` grouped against `maxAttemptsPerContact`, to
234
+ flag **BLOCKED BY ATTEMPT CAP**.
235
+ **Q4** — open attempts (`dtStarted IS NOT NULL AND dtEnded IS NULL`) with hours-open; these wedge a
236
+ contact out of **all** future dialing.
237
+ **Q5** — active-campaign config sanity: flag blank `timezone` and expired `dateEnd`.
238
+
239
+ Also:
240
+ - Grep the worker log / Sentry for **`Cannot parse callbackTime`** — **every hit is a lost callback.**
241
+ - In `Core`, surface the silent throw:
242
+ ```sql
243
+ SELECT id, dtCompleted, failureReason FROM WorkerJobs
244
+ WHERE isSuccess = 0 AND failureReason LIKE '%Vapi%'
245
+ ORDER BY dtCompleted DESC LIMIT 50;
246
+ ```
247
+
248
+ ## Verification gaps (do not treat these as confirmed)
249
+
250
+ - **No production DB access from a dev laptop** (the client DB needs VPN) — every production claim
251
+ above is a **code/schema inference pending Q1–Q5**.
252
+ - **The live Vapi assistant config could not be inspected.** `C:\WWW\info` is the Next.js funnel:
253
+ no `vapi/` templates, no `scripts/`, zero matches for `callbackTime`. So it is **unconfirmed**
254
+ whether the live structured-output schema already constrains the field, and which server-message
255
+ types each assistant currently subscribes to.
256
+ - **Parser results verified on PHP 8.1.10 only** (an `upgrade-to-php8.5` branch exists; PHP's
257
+ relative-date grammar has been stable across 7.x–8.x, so results are not expected to differ).
258
+ - **Vapi's actual event ordering** — whether a `status-update` can land *after* the
259
+ `end-of-call-report` — could not be observed.
260
+ - **api2's PUT semantics for an explicit `null` were not confirmed from source.** If api2 ignores
261
+ an explicit null, the clearing hazard in the webhook doc's concurrency gotcha disappears. Worth a
262
+ one-line check.
263
+
264
+ ## Change history
265
+ - 2026-07-30 — Initial doc. Read-only root-cause investigation of the production "call me back
266
+ never happens" bug (worker2 `_production` @ `ef50345`, `_underscore` `_production` @ `5047d135`).
267
+ Root cause: `convertDateTime` returns null for 22/32 realistic phrasings, so
268
+ `actionScheduleCallback` aborts before writing `dtNextContactRequested`. **Corrects** the earlier
269
+ "spelled-out numbers" attribution — the real trigger is the preposition/filler word (`in`, `at`,
270
+ `around`, `sometime`); `"tomorrow at 3pm"` (the phrasing the code comment claims works) fails,
271
+ ISO 8601 parses cleanly. Ruled out the downstream wiring (the write, the dialer bypass, and
272
+ `CALLBACK_SCHEDULED` not being terminal are all fine). Also recorded: parsed times are often
273
+ wrong/in the past with call windows bypassed (compliance exposure); `maxAttemptsPerContact` is
274
+ **not** bypassed for callbacks; the failure is silent out to Vapi (HTTP 200, no retry); plus the
275
+ fix priority order and the Q1–Q5 production queries. Diagnosis only — no code changed; handed off
276
+ to bala. (tcox)
277
+ </content>
278
+ </invoke>
@@ -6,13 +6,14 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-29
10
- owners: [snaredla, tcox]
9
+ updated: 2026-07-30
10
+ owners: [snaredla, tcox, bala]
11
11
  files:
12
12
  - worker2/Worker/Vapi.php
13
13
  - worker2/Worker/Ai/Bdr/Vapi.php
14
14
  - worker2/Controller/Index.php
15
15
  related:
16
+ - callback-scheduling.md
16
17
  - ../../ai-bdr/features/call-orchestration.md
17
18
  - ../../ai-bdr/features/vapi-integration.md
18
19
  - ../../ai-bdr/features/live-call-status.md
@@ -114,6 +115,30 @@ other types before they reach `processVapiEndOfCallPayload()`.
114
115
  - **Symptom:** `Warning: Undefined property: stdClass::$contactAttemptUuid` (promoted to an
115
116
  `ErrorException` by the framework error handler, captured by Sentry).
116
117
 
118
+ ### The guard is STILL ABSENT on `_production` — and it is a concurrency hazard, not a data-quality one
119
+
120
+ Confirmed 2026-07-30 on `_production` @ `ef50345`: the string `end-of-call-report` appears **only
121
+ in the sample-payload comment at `Worker/Vapi.php:14`**, and a grep for `message->type` across all
122
+ of worker2 returns **zero hits**. `Webhook()` calls `processVapiEndOfCallPayload()`
123
+ **unconditionally** (`:71–79`). Verified consequences:
124
+
125
+ - **`dtEnded` is force-stamped `NOW()` on EVERY event** (`?: date(…)` at `:135`). So a **mid-call
126
+ `status-update` closes the attempt while the call is still live**, which releases the
127
+ in-progress lock at `Ai/Bdr/Vapi.php:305–313` and lets the dialer place **a second concurrent
128
+ call to the same person, mid-conversation.** `status-update` is live-subscribed on the
129
+ healthcare assistant ([vapi-integration.md](../../ai-bdr/features/vapi-integration.md)).
130
+ - It also **NULLs `Contacts.dtNextContactRequested` on every event** (`:268–281`).
131
+ - **Ruled out:** it does *not* blank `c_callbackTime` / `c_callOutcome` — the `!== ''` guards at
132
+ `:189–215` prevent that.
133
+ - **Replay hazard.** Within a single request the order is clear-then-set, so one event is
134
+ self-consistent. But a late or duplicate event arriving **after** the report re-runs
135
+ `actionScheduleCallback` against the **raw stored string re-anchored to a new "now"** — so
136
+ relative times drift forward on each replay, and unparseable ones clear
137
+ `dtNextContactRequested` **without restoring it**, leaving it permanently NULL.
138
+
139
+ Fix: `if (($message->type ?? '') !== 'end-of-call-report') return 'ok';` at `:71–79`, and stop
140
+ force-stamping `dtEnded` from `NOW()` — set it only from a real `endedAt`.
141
+
117
142
  ## Inbound / test calls vs worker-placed outbound calls
118
143
 
119
144
  The metadata shape depends on **who placed the call**, not the event type:
@@ -194,6 +219,13 @@ above is easy to over-read:
194
219
  The exclusion (L314–331: `c_dncRequested`, `DO_NOT_CALL`, `MEETING_BOOKED` / `DNC_REQUESTED`
195
220
  outcomes) applies only on the non-requested path; a customer who actively re-engages
196
221
  (`dtNextContactRequested`) is dialed regardless of a prior terminal outcome.
222
+ - **The attempt cap is the ONE gate the requested path does NOT bypass** (source-verified
223
+ 2026-07-30). The `attemptCount < Campaigns.maxAttemptsPerContact` predicate sits **outside** the
224
+ `dtNextContactRequested` OR-branch (`Ai/Bdr/Vapi.php:234–235`; the branch starts at `:238`), and
225
+ the default cap is **3**. So a prospect who asks for a callback on their **3rd** attempt gets
226
+ `dtNextContactRequested` set correctly and is then **permanently ineligible** — the cap silently
227
+ overrides an explicit customer request. See
228
+ [callback-scheduling.md](callback-scheduling.md).
197
229
  - **The normal (non-requested) path requires a `CampaignCallWindows` row to EXIST for the current
198
230
  day/time.** A campaign with **no** call-window rows can therefore **only ever dial contacts with a
199
231
  pending `dtNextContactRequested`** — this is the **safest launch posture for a web-funnel-only
@@ -239,19 +271,30 @@ character — a 37-char value) **passes** the readiness gate and the campaign is
239
271
  Vapi `POST /call/phone` **400s** at call time. Validate the assistant identifier's shape before
240
272
  assigning an assistant to any campaign.
241
273
 
242
- ### Gotcha (HIGH — call quality) — a spoken callback time with a SPELLED-OUT number is silently dropped
274
+ ### Gotcha (HIGH — call quality) — most spoken callback times are silently dropped
275
+
276
+ > **CORRECTED 2026-07-30.** This gotcha previously blamed **spelled-out numbers**
277
+ > (`"in ten minutes"`). That attribution was **understated and misleading** — the real trigger is
278
+ > the **preposition / filler word**, and the failure rate is far higher than the old wording
279
+ > implied (**22 of 32** realistic phrasings return null, measured on PHP 8.1.10).
280
+
281
+ `actionScheduleCallback` (`Worker/Vapi.php` ~L387–431) parses `structured.callbackTime` through
282
+ `convertDateTime` (~L609–635), which leans entirely on PHP's relative-date grammar. When it
283
+ returns null the method `error_log`s `"Cannot parse callbackTime"` and **`return`s early**
284
+ (`:399–406`) — `dtNextContactRequested` is never set and `callType` is never flipped to
285
+ `CALLBACK`, so the promised callback **never happens** and nothing surfaces the failure.
286
+
287
+ The words **`in`, `at`, `around`, `sometime`** break the parse: `"10 minutes"` works but
288
+ `"in 10 minutes"` does not; `"tomorrow 3pm"` works but **`"tomorrow at 3pm"` — the exact example
289
+ the code comment at `:399` claims works — does not.** Also null: `later`, `Monday at 9am`,
290
+ `in an hour`, `in 2 days`, `this afternoon`, `tomorrow morning`, `end of day`, `15 minutes from now`.
291
+ **ISO 8601 parses cleanly** (`2026-08-01T15:00:00Z` and the `.000Z` variant), which is why the
292
+ durable fix belongs in the Vapi structured-output schema.
243
293
 
244
- `actionScheduleCallback` (`Worker/Vapi.php` ~L387–431) parses the assistant's
245
- `structured.callbackTime` through `convertDateTime` (~L609–635), which relies on PHP's date
246
- parser. PHP handles relative phrasings like `"10 minutes"` and `"tomorrow at 3pm"` — but **NOT
247
- spelled-out numbers**. When the model emits `c_callbackTime = "in ten minutes"` (natural language,
248
- number as a word), `convertDateTime` returns **null**, `actionScheduleCallback` `error_log`s
249
- `"Cannot parse callbackTime"` and **aborts** — `dtNextContactRequested` is never set and `callType`
250
- is never flipped to `CALLBACK`, so the promised callback **never happens** and nothing surfaces the
251
- failure. Reproduced live 2026-07-21 (attempt 183, contact 248). Two recommended fixes (do both):
252
- (a) constrain the Vapi structured-output `callbackTime` schema to ISO 8601 / numeric-relative so the
253
- model can't spell the number out (see `../../ai-bdr/features/vapi-integration.md`); (b) defensively
254
- normalize spelled-out numbers (`one`..`sixty`) in `convertDateTime` before parsing.
294
+ **Full root-cause analysis, three further defects on the same path (wrong/past times with call
295
+ windows bypassed; `maxAttemptsPerContact` not bypassed for callbacks; the silent HTTP-200
296
+ failure), the fix priority order, and the production diagnostic queries live in
297
+ [callback-scheduling.md](callback-scheduling.md).**
255
298
 
256
299
  ### Gotcha (SECURITY) — the Vapi API bearer token is hardcoded in source
257
300
 
@@ -287,7 +330,13 @@ status. See the `ai-bdr` skill / `_Model_True_ContactAttempt` for the full field
287
330
  `c_vapiCallIdentifier`.
288
331
  - **No message-type guard = errors.** Always filter to `end-of-call-report` first.
289
332
  - **Persistence is via the Toga REST API**, not a direct model `save()` — failures are
290
- caught and logged, not re-thrown.
333
+ caught and logged, not re-thrown. **One exception:** `actionScheduleCallback`'s
334
+ `PUT /contacts/{uuid}` (`:420–430`) is the only write in the file **without** a try/catch
335
+ (compare `:255–284` and `:363–367`) and is called with `$throwExceptionOnApiError = true`
336
+ (`_underscore Component/Api/Toga/Toga.php:167`, `:172` throw). Because
337
+ `Controller/Index.php:511–526` swallows the throw and still returns **HTTP 200**, Vapi never
338
+ retries and the callback is lost with **no external signal** — the only trace is
339
+ `Core.WorkerJobs.failureReason`.
291
340
  - **Timestamps are stored in Central Time**; Cal.com booking converts `c_meetingTime` back to
292
341
  UTC.
293
342
 
@@ -296,6 +345,20 @@ status. See the `ai-bdr` skill / `_Model_True_ContactAttempt` for the full field
296
345
  - [VAPI Integration — assistants, tools, structured output](../../ai-bdr/features/vapi-integration.md)
297
346
 
298
347
  ## Change history
348
+ - 2026-07-30 — **Corrected** the HIGH callback gotcha: the parse failure is driven by the
349
+ **preposition/filler word** (`in`, `at`, `around`, `sometime`), **not** spelled-out numbers —
350
+ `"tomorrow at 3pm"` (the phrasing the code comment claims works) fails, and 22/32 realistic
351
+ phrasings return null. Split the full root cause + three further defects + fix order + production
352
+ queries into the new [callback-scheduling.md](callback-scheduling.md). Also, from the same
353
+ read-only `_production` @ `ef50345` investigation: the **`message.type` guard is still absent**
354
+ (zero `message->type` hits repo-wide) and is a **concurrency hazard** — `dtEnded` is force-stamped
355
+ `NOW()` on every event, so a mid-call `status-update` closes the attempt, releases the
356
+ in-progress lock and lets the dialer place a second concurrent call to the same person; plus a
357
+ replay hazard that can leave `dtNextContactRequested` permanently NULL. Recorded that the attempt
358
+ cap is the one gate the requested path does **not** bypass, and that
359
+ `actionScheduleCallback`'s write is the file's only un-try/catch'd write while the controller
360
+ still returns HTTP 200 (silent loss, no Vapi retry). Diagnosis only — no code changed; handed off
361
+ to bala. (tcox)
299
362
  - 2026-07-23 — Broadened the SECURITY credential-rotation gotcha: the AI-BDR rotation ticket now
300
363
  also covers the **HubSpot access token** and **Toga API client secret** committed to git in the
301
364
  old `info` repo's tracked `.env.*` files (treat as compromised), alongside the hardcoded Vapi
@@ -0,0 +1,201 @@
1
+ ---
2
+ type: session
3
+ slug: bdr-live-call-status-and-callback-bug
4
+ title: BDR funnel live call status + real summary email, and AI-BDR callback root cause
5
+ author: tcox
6
+ repos: [bdr, worker2]
7
+ framework: "2.0"
8
+ client: shared
9
+ status: active
10
+ created: 2026-07-30
11
+ updated: 2026-07-30
12
+ ---
13
+
14
+ # Session: bdr-live-call-status-and-callback-bug
15
+ **Date:** 2026-07-30
16
+ **Project/Repo:** bdr (2.0), plus a read-only worker2 investigation
17
+ **Task:** Make the BDR funnel usable by anyone (remove the contact-reference gate), drive the Success screen from real call data instead of a timed simulation, send the call summary by real email, and then root-cause a production bug where a callback requested during an AI-BDR call never happens.
18
+
19
+ ---
20
+
21
+ ## What WORKED
22
+
23
+ - **Contact-reference gate removed.** Deleted the `hasLead` submit-block and the
24
+ `phone.missingContactNotice` copy key from `src/flow/screens/CallNow.tsx`,
25
+ `src/flow/screens/Schedule.tsx`, `src/content/schema.ts`, `src/content/default.ts`.
26
+ Evidence: grep for `hasLead|missingContactNotice` across the repo returns zero matches;
27
+ design-parity audit against `C:\WWW\BDR\mockup` confirmed the removal restored mockup
28
+ parity (the mockup never had the notice).
29
+ - **Vapi's own API confirmed as a working live call-status source.** `GET https://api.vapi.ai/call`
30
+ returned HTTP 200 with the org's calls. Verified field shape: `id, status, type, createdAt,
31
+ startedAt, endedAt, endedReason, summary, transcript, recordingUrl, customer, analysis,
32
+ messages, cost`. Status enum `queued|ringing|in-progress|forwarding|ended`. Timestamps are
33
+ ISO-8601 UTC with `Z`. **Key fact: `createdAt` = placed, `startedAt` = ANSWERED,
34
+ `endedAt` = hangup.** A real call showed `createdAt 18:53:08 / startedAt 18:53:18 /
35
+ endedAt 18:53:35, endedReason "voicemail"` — the 10s gap is ring time.
36
+ - **Fixed the worker dispatcher reply-prefix bug** in `src/server/workerDialer.ts`. The worker
37
+ wraps every reply as `[<requestId>] Successfully Executed (<n> seconds): <json>`, so the old
38
+ `response.json()` always threw: it logged "immediate dial not placed" on every SUCCESSFUL
39
+ dial and discarded the response body. Now strips the prefix before parsing and returns a
40
+ typed `DialResult` carrying `vapiCallId` / `attemptId`. Evidence: regression test asserts a
41
+ dispatcher-wrapped success reply no longer logs the false warning.
42
+ - **Live call lifecycle on the Success screen.** New `/api/call-status` route, `vapiCall.ts`
43
+ server reader, `useCallStatusViewModel` polling hook. Mapping: `queued`/`ringing` -> dialing
44
+ with NO talk timer; `in-progress`/`forwarding` with `startedAt` -> active, timer counted from
45
+ the ANSWER time with server-clock-offset correction; `ended` -> frozen true talk duration;
46
+ ended with no `startedAt` or a voicemail/no-answer/busy reason -> a distinct not-answered
47
+ state that shows no timer and suppresses the recap card. The old simulation is retained
48
+ verbatim as the fallback whenever status is unavailable.
49
+ - **Post-hangup summary grace.** Vapi writes `summary` asynchronously after hangup, so the
50
+ first ended poll is usually `summary: null`. Added a bounded 30s / 2s-cadence grace that
51
+ keeps polling after ended until the real summary lands, with the screen already showing the
52
+ ended state. Frozen duration provably unchanged across the window (tested).
53
+ - **Real email send of the call summary.** New `/api/share-summary` route + `src/server/mailer.ts`
54
+ (nodemailer) + shared `src/lib/summaryText.ts`, so the Copy button and the emailed body are
55
+ byte-identical by construction. The client may only supply recipient + campaign id + bounded
56
+ variant index + signed token; never body or subject text.
57
+ - **mailto fallback removed entirely** (developer decision mid-session). `src/lib/shareMailto.ts`
58
+ and its test deleted; Send now only ever reaches sent or failed-with-retry.
59
+ - **Security hardening after the CSO audit returned BLOCK** (it flagged an unauthenticated
60
+ arbitrary-recipient mailer). Applied: purpose-scoped HMAC tokens (`status` vs `share`), token
61
+ moved from the URL query string to an `x-status-token` header, a valid share token now
62
+ REQUIRED for every send, Content-Type and `sec-fetch-site` checks, strict recipient validation
63
+ (rejects angle-bracket display-name form, commas, all non-ASCII), SMTP `requireTLS` + TLS 1.2
64
+ + timeouts, `Cache-Control: no-store, private` on every envelope, exact ACL error-code matching
65
+ that fails CLOSED on an unknown 403, upstream AbortSignal timeouts, rejection of future-dated
66
+ token issuance. The Vapi call id travels only inside signed claims, never as a client param.
67
+ - **Final verification:** `npx tsc --noEmit` clean, `npm run lint` clean, `npm test`
68
+ **306/306 passing across 20 files**.
69
+ - **Credentials located and wired** (locations only): 2.0 SMTP settings are hardcoded constants
70
+ in `_underscore/Email.php` (SES us-west-2, port 587, TLS); the Vapi bearer credential is a
71
+ hardcoded constant in `worker2/Worker/Ai/Bdr/Vapi.php`. BDR consumes both as server-only env
72
+ vars `TOGA_SMTP_HOST/PORT/USER/PASS`, `TOGA_EMAIL_FROM`, `TOGA_VAPI_TOKEN` in the gitignored
73
+ `.env.local` (documented in `.env.example`).
74
+ - **Two knowledge captures pushed:** `6ed04d3` (8 docs — live-call-status, the api2 v2 query
75
+ contract, and updates) and `968404e` (3 docs — the callback root cause, with `bala` added as
76
+ co-owner for handoff).
77
+ - **Callback bug root-caused with empirical proof.** Extracted worker2's `convertDateTime` and
78
+ executed it on PHP 8.1.10: 22 of 32 realistic phrasings return null.
79
+
80
+ ## What did NOT work — DO NOT RETRY THESE
81
+
82
+ - **Reading `ContactAttempts` through api2 — HTTP 403 `EZ-1`, record-level block.** Re-verified
83
+ live on 2026-07-30 against `api.togahub.com` with the BDR client credential, on ALL variants:
84
+ nested `/contacts/{uuid}/contact-attempts`, flat `/contact-attempts`, both with and without an
85
+ explicit `fields=` list. Response is `data.contactAttempts: null`. The pending read-only ACL
86
+ grant has still NOT landed. Do not build anything against this resource until it does.
87
+ - **Selecting `Contacts.id`** — `/contacts/{uuid}?fields=id` returns 403 `EZ-2` with
88
+ `identifiers.fields: ["id"]`. The client has no field grant on `Contacts.id`. Filter on
89
+ `Contacts.uuid` instead (api2 does not ACL-check `where`/`join`, only `fields`).
90
+ - **Direct production database access from this laptop.** TCP 3306 to the
91
+ `*.client.database.togahub.com` writer host **times out** (VPN required). This machine also
92
+ has no mysql client, no PHP, and no Python (only the Windows Store stub). Do not plan work
93
+ that assumes direct SQL — hand the developer ready-to-run SQL with pre-flight reads instead.
94
+ - **Reading the api2 auth token from `data.token`** — wrong path, the response has no such key.
95
+ The access token is at **`data.tokens.access`**.
96
+ - **Sharing the raw Claude Code session transcript (`~/.claude/projects/c--WWW/<id>.jsonl`) for
97
+ handoff.** Verified it contains live credential VALUES in plaintext: SMTP password (5
98
+ occurrences), Vapi token (2), Toga client API secret (4), HubSpot access token (4). Use
99
+ `/session-save` + `/session-resume` and the knowledge docs for handoff instead.
100
+ - **(worker2, the production bug) `convertDateTime` parsing natural-language callback times.**
101
+ 22/32 phrasings return null on PHP 8.1.10. The trigger is the PREPOSITION/FILLER WORD, not
102
+ spelled-out numbers as previously recorded: `"10 minutes"` parses but `"in 10 minutes"` does
103
+ not; `"tomorrow 3pm"` parses but `"tomorrow at 3pm"` does not — and that is the exact example
104
+ the code comment claims works.
105
+
106
+ ## Not tried yet (candidates for next session)
107
+
108
+ - A **live end-to-end call test** of the new UI. Not possible yet: campaign 2's `dateEnd` lapsed
109
+ on 2026-07-28 and the re-activation SQL had not been run at save time.
110
+ - **Verifying a real SMTP send.** Credentials are wired but no successful delivery has been
111
+ observed; `TOGA_EMAIL_FROM=DevTeam@goagilant.com` may or may not be an SES-verified identity.
112
+ - **Running Q1-Q5 from `2.0/apps/worker2/features/callback-scheduling.md`** against `Client_True`
113
+ to confirm the callback root cause with production data. Q1 is decisive.
114
+ - **Fixing any of the worker2 callback defects** — this session was diagnosis only; no worker2
115
+ code was modified. bala is picking this up.
116
+ - **Adding the `TOGA_*` env vars to the Amplify console** so the deployed site gets live status
117
+ and email (local `.env.local` only affects dev).
118
+ - **Binding the email recipient to the contact's on-file address** — the stricter anti-abuse
119
+ option; currently a token holder can email an arbitrary recipient (accepted residual risk).
120
+ - **Splitting BDR knowledge out of `2.0/apps/ai-bdr/` into its own `2.0/apps/bdr/` folder with an
121
+ `architecture.md`.** Flagged twice; an elevated call that needs a deliberate decision.
122
+
123
+ ## Current file state
124
+
125
+ All BDR changes are **uncommitted on branch `TRUE-80435`** (working tree only — nothing was
126
+ committed or pushed all session, per the git-workflow rule).
127
+
128
+ | File | Status | Notes |
129
+ |------|--------|-------|
130
+ | `bdr src/flow/screens/CallNow.tsx` | modified | gate + notice removed; submit enables on the digit rule alone |
131
+ | `bdr src/flow/screens/Schedule.tsx` | modified | same removal |
132
+ | `bdr src/flow/screens/Success.tsx` | modified | live stages via a kind-keyed lookup; simulation path preserved verbatim |
133
+ | `bdr src/flow/screens/CallSummary.tsx` | modified | async send with sending/sent/failed; real-summary rendering; no mailto |
134
+ | `bdr src/flow/useCallStatusViewModel.ts` | new | poll loop + pure reducer, cadence, caps, summary grace |
135
+ | `bdr src/flow/callStatusApi.ts` | new | client fetcher, sends the token in a header |
136
+ | `bdr src/flow/shareSummaryApi.ts` | new | maps send outcomes to sent/unavailable/failed |
137
+ | `bdr src/flow/leadActions.ts` | modified | returns a handle `{ok, uuid?, statusToken?, shareToken?}`; `simulated` flag |
138
+ | `bdr src/flow/useAgentFlow.ts` | modified | stores the call handle under the submitGen guard |
139
+ | `bdr src/flow/AgentFlowRoot.tsx` | modified | passes the handle to calling-mode Success only |
140
+ | `bdr src/server/vapiCall.ts` | new | reads `GET api.vapi.ai/call/{id}`; maps to the CallStatus union |
141
+ | `bdr src/server/callStatus.ts` | modified | ContactAttempts path retained as the (403) fallback |
142
+ | `bdr src/server/statusToken.ts` | new | purpose-scoped HMAC, TTL, skew, optional `vapiCallId` claim |
143
+ | `bdr src/server/mailer.ts` | new | nodemailer, requireTLS, timeouts, scrubbed logs |
144
+ | `bdr src/server/workerDialer.ts` | modified | **prefix bug fixed**; returns `DialResult` with `vapiCallId` |
145
+ | `bdr src/server/callbackService.ts` | modified | surfaces `vapiCallId` |
146
+ | `bdr src/server/toga.ts` | modified | added `togaApiGetEnvelope` (needed to read EZ-1/EZ-2 out of a 403) |
147
+ | `bdr src/lib/summaryText.ts` | new | single renderer shared by Copy and the email |
148
+ | `bdr src/lib/emailFormat.ts` | new | strict recipient validation |
149
+ | `bdr src/lib/callStatus.ts` | new | shared CallStatus union + header constant |
150
+ | `bdr src/app/api/call-status/route.ts` | new | token-gated, never 500s, `no-store` |
151
+ | `bdr src/app/api/share-summary/route.ts` | new | share-token required, server-built content only |
152
+ | `bdr src/app/api/call-now/route.ts` | modified | mints both tokens with the `vapiCallId` claim |
153
+ | `bdr src/lib/shareMailto.ts` | **deleted** | mailto fallback removed by developer decision |
154
+ | `bdr src/content/schema.ts` + `default.ts` | modified | live-stage + send-state copy; notice key removed |
155
+ | `bdr src/proxy.ts` | modified | matcher + limiters for the two new routes |
156
+ | `bdr .env.example` | modified | documents SMTP + `TOGA_VAPI_TOKEN` |
157
+ | `bdr .env.local` | modified | real values added locally (gitignored) |
158
+ | `bdr package.json` | modified | added `nodemailer` + `@types/nodemailer` |
159
+ | worker2 / _underscore / api2 | **unmodified** | read-only investigation only |
160
+
161
+ ## Decisions made
162
+
163
+ - **Vapi's API is the live call-status source, not `ContactAttempts`.** Rationale: ContactAttempts
164
+ is 403-blocked AND structurally inferior — `dtStarted` is DIAL time, so it cannot distinguish
165
+ ringing from talking, and `dtStarted` is retroactively rewritten by the webhook. Vapi exposes
166
+ ringing vs answered vs ended plus a real summary. It stays the better source even after the
167
+ grant lands. Rejected: waiting for the ACL grant; continuing to simulate.
168
+ - **The backend contract is unchanged for anonymous submits** (developer decision). The funnel
169
+ loads and accepts a phone number for anyone, but calls still only fire for campaign-linked
170
+ contacts, matching the original BDR backend. Rejected: server-side anonymous contact creation
171
+ (would have required a DNC pre-check that the 403 blocks anyway).
172
+ - **The mailto fallback was removed entirely** (developer decision). Consequence, accepted: with
173
+ SMTP unconfigured the Send button can only reach the failed state.
174
+ - **Token purpose-scoping and header transport**, adopted in response to the CSO BLOCK verdict.
175
+ Rejected: leaving one token to serve both read and send, and leaving it in the query string
176
+ (a 30-minute bearer capability over a named individual's call summary would land in access logs).
177
+ - **Accepted residual risk:** a token holder can email the summary to any address they type
178
+ (preserves the share-with-a-colleague use case). The stricter alternative is on the not-tried list.
179
+ - **BDR knowledge stays filed under `2.0/apps/ai-bdr/`** rather than restructuring mid-capture.
180
+
181
+ ## Blockers
182
+
183
+ - **`ContactAttempts` read-only ACL grant still pending** (403 EZ-1). Blocks only the fallback
184
+ path — the Vapi path works without it. Note for whoever files it: the `c_*` fields are granted
185
+ through `AclCustomFieldPermissions`, a DIFFERENT table from the standard fields.
186
+ - **Campaign 2 (`26.05 - AI BDR - Ryan Nitti`, id 2) `dateEnd` lapsed 2026-07-28**, so no calls
187
+ place at all until it is extended. SQL was handed to the developer; the production DB is not
188
+ reachable from this machine.
189
+ - **No live end-to-end verification yet** of the call UI or a real email delivery.
190
+
191
+ ## Exact next step
192
+
193
+ > In `Client_True`, run the campaign-2 re-activation pre-flight reads first (confirm the dialable
194
+ > count on campaign 2 is 0 or 1 and that contact 248 has no open `ContactAttempts` row with
195
+ > `dtEnded IS NULL`), then flip all campaign-2 contacts except 248 to `isOkayToCall = 0` and
196
+ > extend `Campaigns.dateEnd`. Then restart `npm run dev` in `C:\WWW\BDR`, open the funnel from
197
+ > the `?hsContactId=` link, tap Call Now, and confirm the Success screen shows Ringing with no
198
+ > timer, the timer starting at 00:00 on answer, and Call ended with the real Vapi summary.
199
+
200
+ ---
201
+ _Saved by /session-save on 2026-07-30_
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.481",
3
+ "version": "1.0.483",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",